From 0efb1165e41d925ad7163bde60cc75c5983fb43c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 19:34:17 +0700 Subject: [PATCH 01/12] docs(dashpay): add Kotlin migration spec + PARITY interim correction Reviewed spec (5 lenses: feasibility, scope, security, adversarial, domain-fit) for porting the PR #3841 DashPay completion to the Kotlin SDK, in three stacked milestones (K1 persistence+reads, K2 sync service+seedless unlock+writes, K3 DashPay tab UI). PARITY.md: the FriendsView row claimed parity against a Swift view that PR #3841 deleted; mark the DashPay surface as in-migration instead of counting it ported. Co-Authored-By: Claude Fable 5 --- docs/dashpay/KOTLIN_MIGRATION_SPEC.md | 433 ++++++++++++++++++++++++++ packages/kotlin-sdk/PARITY.md | 8 +- 2 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 docs/dashpay/KOTLIN_MIGRATION_SPEC.md diff --git a/docs/dashpay/KOTLIN_MIGRATION_SPEC.md b/docs/dashpay/KOTLIN_MIGRATION_SPEC.md new file mode 100644 index 00000000000..71b481b3d32 --- /dev/null +++ b/docs/dashpay/KOTLIN_MIGRATION_SPEC.md @@ -0,0 +1,433 @@ +# DashPay — Kotlin/Android Migration Spec + +Port the complete DashPay feature (as shipped for iOS in PR #3841, merged to +`v4.1-dev` 2026-07-06) to the Kotlin/Android SDK and KotlinExampleApp +(PR #3999, branch `feat/kotlin-sdk-and-example-app`). + +Status: **v2 — post five-lens review** (feasibility, scope, security, +adversarial, domain-fit); all must-fixes folded in. +Reference implementation: `packages/swift-sdk` + SwiftExampleApp. +Feature spec: `docs/dashpay/SPEC.md` (+ companion specs in `docs/dashpay/`). + +--- + +## 1. Problem + +The Kotlin SDK (PR #3999) is a one-for-one Android port of SwiftExampleApp, +snapshotted **before** PR #3841 landed. #3841 completed DashPay on iOS: + +- Replaced the utilitarian `FriendsView` (which the Kotlin `FriendsScreen` + mirrors — now a port of a **deleted** Swift view) with a first-class + **DashPay tab**: 10 views, ~3.8k LOC (`Views/DashPay/`). +- Added a recurring **DashPay background sync** service + (`platform_wallet_manager_dashpay_sync_*`, 7 FFI fns). +- Added **payment history**, **cached contact profiles**, **contactInfo** + (alias/note/hidden, cross-device), **ignore tombstones**, **seedless + unlock + deferred contact-crypto drain**, and **DIP-15 auto-accept QR**. +- Net: 23 new C exports in `rs-platform-wallet-ffi`; 6 old exports removed + (incl. the `*reject_contact_request` pair — reject became ignore). + +Today the Kotlin stack has: 3 of 5 DashPay Room entities (field-exact with +SwiftData), the send/accept/ignore/sync contact-request pipeline bridged +(17 JNI exports in `tokens.rs`), and one compressed `FriendsScreen`. It +lacks: profile/contactInfo writes, payments, the contact-profile cache, the +sync service, QR, seedless unlock, wallet-scoped DPNS search, and the +DashPay tab. `PARITY.md` still claims 88/90 ported against the stale +pre-#3841 view list. + +**Compatibility baseline:** the existing 17 exports were reconciled with +the #3841 Rust in commit `2298a2059f` (reject→ignore, `coreSignerHandle` +threading, contacts-vtable ignored-sender deltas + contactInfo metadata +fields, Room v1→v2). That reconciliation was verified by compile + 3 +Robolectric tests only — no runtime exercise of the Android +send/accept/ignore path against a network has been recorded since the +merge. K1 therefore opens with a runtime revalidation gate (§4). + +**Goal:** feature parity with iOS DashPay per the parity doctrine +(`kotlin-sdk/CLAUDE.md`): cite Swift sources in KDoc, reuse iOS +accessibility identifiers as Compose `testTag`s, keep all orchestration in +Rust. + +## 2. What does NOT need porting + +All DashPay *logic* is shared Rust, already compiled into this branch and +consumed by iOS through the same C ABI: + +- Contact-request crypto (ECDH, AES-256-CBC, DIP-15 69-byte compact xpub), + DIP-14/15 derivation, accountReference — `rs-platform-wallet` + + `platform-encryption` + `key-wallet`. +- Accept → reciprocal request → `register_external_contact_account` + (friendship address derivation) — automatic inside Rust. +- The recurring sync sweep (`manager/dashpay_sync.rs`), seed binding + verification, deferred contact-crypto queue, auto-accept QR + build/parse/proof. +- The bundled DashPay contract. + +Kotlin re-implements **none** of this. Per doctrine, no JNI function may +stitch Rust calls together — any composite gap found during the port goes +into `rs-platform-wallet-ffi` as its own reviewed change (none are +currently known to be needed; iOS ships on the existing exports). + +**Deliberately not bridged** (zero non-README callers in the Swift app — +the app reads this data from persisted rows, not handles; verified +function-by-function during review): + +- The 8 `contact_request_*` field getters + `contact_request_create`. +- The 14 `established_contact_*` accessors + + `managed_identity_get_established_contact` / + `_get_sent_contact_request` — `ContactDetailView` reads alias/note/ + hidden/paymentChannelBroken off `PersistentDashpayContactRequest` rows + and writes via `set_dashpay_contact_info_with_signer`. +- `managed_identity_is_contact_established`, + `platform_wallet_pubkey_hash_from_private_key` (no app consumers), + and the `managed_identity`-level send/accept/ignore variants (Kotlin + uses the `platform_wallet_*` composites, as iOS does). + +Consequence: **no new handle-wrapper types.** The existing +`ContactRequestRef` / `EstablishedContactRef` (`tokens/Dashpay.kt`) stay +as-is for the accept path; new screens are driven by Room Flows and +snapshot data classes. Rule: never retain a native handle in Compose +composition — snapshot fields at the JNI boundary (a Cleaner can free a +handle mid-read otherwise). + +## 3. Approach + +Three milestones, re-cut after review so that **every bridged function +lands in the milestone that first consumes and tests it** (the original +bottom-up "bridge everything first" cut concentrated all marshaling +defects at final UAT — rejected). + +### Alternatives rejected + +- **Re-orchestrating DashPay flows in Kotlin**: forbidden by doctrine; + Swift ships proof that single-call composites suffice. +- **Keeping `FriendsScreen` and bolting features onto it**: its Swift + counterpart was deleted; keeping a dead view's port violates parity. +- **uniffi / JNA instead of hand-rolled JNI**: the crate has 160 + hand-rolled exports with established support patterns (`support::guard`, + handle-as-jlong, GlobalRef callbacks); mixing binding generators adds + toolchain cost for no capability gain. +- **Bridging the full ~47-function FFI sweep**: 24 of them have no + consumer anywhere in the reference app (see §2); bridging them would + add dead surface + a speculative handle-wrapper abstraction. + +## 4. Work plan + +New JNI exports go in a new `rs-unified-sdk-jni/src/dashpay.rs` + +`ffi/DashpayNative.kt`; the 17 existing DashPay exports stay in +`tokens.rs` (moving them is a follow-up refactor). Array-free FFI +counterparts (`dashpay_payment_array_free`, `dpns_search_results_free`, +`platform_wallet_manager_free_account_balances`) are consumed Rust-side +inside the JNI wrappers, as the existing exports do. + +⚠ Lockstep rule: extending the identity-entry persist callback changes the +`onPersistIdentityUpsert` JNI method descriptor (hand-written string, +`persistence.rs:866-893`) — the Rust descriptor and the Kotlin +`NativePersistenceBridge` signature must change in the same commit or +every identity persist fails at runtime. + +### Milestone K1 — Persistence completion + the read surface it can prove + +**Entry gate:** one runtime revalidation of the existing 17-export +pipeline — the FriendsScreen send→accept→ignore flow against testnet +(`-Ptestnet=true`), or its instrumented equivalent — before any new +bridging. Also the 15-minute PARITY.md interim fix: drop the stale +`FriendsView.swift` row claims, mark the DashPay section +"in migration — see KOTLIN_MIGRATION_SPEC.md". + +**Bridge (6 exports):** + +| Group | Functions | +|---|---| +| Payments | `managed_identity_get_dashpay_payments` | +| Profile reads | `platform_wallet_get_contact_profile`, `managed_identity_get_dashpay_profile`, `managed_identity_get_dashpay_sync_state` | +| DPNS search | `platform_wallet_search_dpns_names` (wallet-scoped; the existing `QueriesNative.dpnsSearch` wraps the *SDK-scoped* `dash_sdk_dpns_search` — a different call path; AddContactScreen must use the wallet-scoped one for parity) | +| Account balances | `platform_wallet_manager_get_account_balances` (DashPayTabView's per-account balance display; not DashPay-prefixed, easy to miss) | + +**Persistence** (mirrors `PersistentDashpayContactProfile.swift` / +`PersistentDashpayPayment.swift`): + +- New Room entities `DashpayContactProfileEntity` + (`(networkRaw, ownerIdentityId, contactIdentityId)` unique, `checkedAtMs` + backoff) and `DashpayPaymentEntity` + (`(networkRaw, ownerIdentityId, txid)` unique) + DAOs + Flows; + `DashDatabase` v2→v3 additive migration, exported schema `3.json`. +- **Persist direction — contact profiles:** extend identity-entry + marshaling to carry `IdentityEntryFFI.contact_profiles` (slot exists, + currently skipped at `persistence.rs:825-895`). **Tombstone semantics + are load-bearing:** the projection emits `is_present == false` rows that + mean DELETE the persisted row (`identity_persistence.rs:600-608`) — an + upsert-only implementation compiles, passes upsert tests, and leaves + stale contact names/avatars forever. Required: `is_present=false` → DAO + delete (with test) + `checkedAtMs` round-trip fidelity test. (Note: the + outer doc comment at `identity_persistence.rs:146` contradicts the + projection code and should be corrected in passing.) +- **Persist direction — payments:** pull-based, mirroring iOS exactly. + **Invariant:** payment rows reach Room *only* via the + `refreshDashPayPayments` equivalent (FFI read → Room upsert); + `dashpay_sync_now` reconciles payments **in-memory without persisting** + (identity persist skips payments, `identity_persistence.rs:37-39`). + Android process death is aggressive, so K3's send flow must call + refresh-after-send, and the K1 test suite pins the invariant. +- **Restore direction:** populate the null-stubbed `contact_profiles` / + `payments` arrays in `rs-unified-sdk-jni/src/persistence.rs` + (staging comment :1476-1480, stubs :1848-1853, free-path :2049-2051), + mirroring the Swift restore blocks + (`PlatformWalletPersistenceHandler.swift` ~4918, ~4978). + +**Gate (re-tiered after review):** the persist→wipe→restore→re-read +round-trip runs as an **instrumented test** (extend +`sdk/src/androidTest/.../WalletManagerRoundTripTest.kt`, which already +drives the real native lib on the CI emulator) — payload includes payments +with memo/direction/status, a contact-profile `is_present=false` tombstone, +and ignored senders; the new K1 getters read back restore-injected fixtures +and assert field equality. JVM/Robolectric tests cover handler↔Room mapping +only (they never load the native lib, so they cannot see marshaling bugs — +this was the original spec's top-listed risk guarded by the wrong tier). + +### Milestone K2 — Sync service, seedless unlock, writes + +**Pre-req (security must-fix): mnemonic-handling discipline.** The +existing Kotlin resolver materializes the mnemonic as an immutable JVM +`String` (`WalletStorage.retrieveMnemonic` does `decodeToString()` and +scrubs only the ByteArray; `MnemonicResolverAndPersister.kt:36-42` returns +the String to native). iOS never creates a string-shaped copy: it keeps +XOR-masked UTF-8 bytes (`MaskedMnemonicUTF8`) and writes into Rust's +out-buffer, scrubbing on every access. K2 multiplies resolver calls (the +drain runs it once per queued entry), so **before** wiring the automatic +drain: port the out-buffer + masked-bytes discipline to `WalletStorage.kt` +/ `MnemonicResolverAndPersister.kt` / `mnemonic.rs` (whose "same residual +exposure as iOS" comment is false and must be corrected), and zeroize the +`mnemonic_str`/`mnemonic_c` copies in `signer.rs:342-422` (currently only +`sig_buf` is scrubbed). + +**Bridge (13 exports):** the 7 `platform_wallet_manager_dashpay_sync_*` +fns; the seedless trio `platform_wallet_verify_seed_binds_to_wallet`, +`platform_wallet_pending_contact_crypto_count`, +`platform_wallet_drain_pending_contact_crypto` (takes **both** a +`SignerHandle` and a `MnemonicResolverHandle` — +`rs-platform-wallet-ffi/src/dashpay.rs:757-761`); +`platform_wallet_create_or_update_dashpay_profile_with_signer`, +`platform_wallet_set_dashpay_contact_info_with_signer`; +`dash_sdk_resolver_supports_key_type` (rs-sdk-ffi; consumed by the +production signer — Swift `KeychainSigner.swift`). + +**`DashpaySyncService`** (`sdk/services/`, mirroring +`PlatformWalletManagerDashPaySync.swift`): + +- Owned by the `PlatformWalletManager` instance; started when platform + wallets are present (after load / on the rebind path), stopped and + disposed on the `WalletManagerStore` manager swap. **Not gated on + process lifecycle** — iOS keeps the sweep running while backgrounded + (the OS suspends the process; Android freezes/kills similarly under + modern app-standby), and this matches the manager-owned ownership + doctrine. (The v1 spec's "mirrors scenePhase" claim was wrong — Swift + drives start/stop off wallet-presence/rebind `.onChange`, not + scenePhase. No `lifecycle-process` dependency needed.) +- `isSyncing` / `lastSync` / `pendingAccountBuilds` exposed as `StateFlow`, + updated by a **1 Hz poll with change-gated assignment and stale-key + pruning** — pinned now, not "verify later": that is exactly how iOS does + it (`PlatformWalletManager.swift:1140-1186`; the comment at :1133-1139 + documents why naive re-assignment burned CPU). Natural home: the + `SpvProgressPublisher` pattern. + +**Seedless unlock — invocation topology (domain must-fix; the API method +alone is not the feature):** + +- `unlockWalletFromKeystore(walletId)` = scoped verify → drain, and is + called **automatically, per restored wallet, inside + `loadPersistedWallets`**, best-effort/never-throwing (mirrors + `PlatformWalletManager.swift:477-506`; Kotlin's + `PlatformWalletManager.kt:457` currently documents the absence). This is + load-bearing: the deferred contact-crypto queue is **in-memory by + design** (no persisted table on either platform — do not "fix" that) and + recovery is self-healing only if every launch runs + load → unlock (verify+drain) → sweep. Banner-triggered-only unlock would + leave contacts never finishing establishment after process restart. +- **Seed-mismatch contract:** Rust `SeedMismatch` surfaces as + `ErrorInvalidParameter` (`rs-platform-wallet-ffi/src/dashpay.rs:960-965`); + like Swift, Kotlin disambiguates *only* by scoping the catch to the + verify call (the JNI error code arrives as + `code + PWFFI_CODE_OFFSET`). Publish per-wallet + `draining` / `seedMismatch` / `pendingAccountBuilds` as `StateFlow` + (Swift: `dashPayUnlockStatus`). +- **Re-entrancy guard:** a second unlock while `draining == true` returns + immediately (Swift :622-624) — load-time auto-drain and a user banner + tap must not double-run the ECDH work. +- **Biometric interaction (Android-only failure mode):** Kotlin's + identity-key Keystore alias is auth-gated (30 s validity + + `BiometricGate`) — *stricter than iOS*, whose identity keys are not + auth-gated at all. The reciprocal-accept signing inside a background + drain can therefore throw on an expired auth window with no Activity to + re-prompt. Required behavior: catch, leave the entry queued (the sweep + self-heals), reflect it in the unlock status; instrumented test for the + auth-expired path. +- **Breadcrumb backfill — explicitly not ported.** Swift's unlock also + schedules `scheduleBackfillIdentityKeyBreadcrumbs`, an iOS-legacy + Keychain healing step for pre-breadcrumb installs. The Kotlin SDK is new + — every identity key it has ever created is breadcrumbed at creation — + so there is nothing to heal. Recorded here so its absence is a decision, + not an oversight. + +**Gate:** unit tests + instrumented sync-service lifecycle tests +(start/stop/isRunning; manager-swap disposal; double-start), unlock state +machine with a wrong-seed fixture (seedMismatch path) and the +auth-expired-drain path; write paths against testnet behind +`-Ptestnet=true`. + +### Milestone K3 — DashPay tab UI + parity bookkeeping + +**Bridge (2 exports):** `platform_wallet_build_auto_accept_qr`, +`platform_wallet_send_contact_request_from_qr` (first consumed here). + +Navigation restructure (mirrors Swift `ContentView.swift`): + +- `RootTab`: `SYNC, WALLETS, IDENTITIES, DASHPAY, SETTINGS` — **Contracts + tab is demoted into Settings** (`ContractsHome` becomes an entry in the + Settings screen's Platform section, as on iOS). +- Retire `FriendsScreen` + its route; entry points repoint at the DashPay + tab/contact flows. +- Reset the DashPay tab's identity-picker selection on network switch — + retained Compose state would otherwise query a wallet absent on the new + network-locked manager. + +Port the 10 Swift views (Compose screens under `ui/dashpay/`, one file per +Swift file, testTags = iOS accessibility identifiers, screens driven by +Room Flows / snapshot data classes — never by retained native handles): + +| Swift (`Views/DashPay/`) | Compose target | Notes | +|---|---|---| +| DashPayTabView (909) | `DashPayTabScreen` | identity picker, per-account balances, pull-to-refresh → `syncNow`, unlock banner (reads unlock-status Flow), sub-sheet navigation | +| ContactsView (299) | `ContactsScreen` | Room Flow over established contacts (both-direction join) | +| ContactRequestsView (391) | `ContactRequestsScreen` | incoming accept/ignore + outgoing pending | +| AddContactView (497) | `AddContactScreen` | wallet-scoped DPNS prefix search (300 ms debounce), raw id, QR entry | +| ContactDetailView (561) | `ContactDetailScreen` | payment history (refresh→Room), alias/note editors **surfacing the `ContactInfoPublishOutcome`** — `DeferredUntilTwoContacts` means local-only until ≥2 established contacts and the UI must say so (parity), `SkippedWatchOnly` likewise; hide; send-payment entry | +| SendDashPayPaymentSheet (386) | `SendDashPayPaymentSheet` | amount/memo → `sendDashPayPayment` → txid, then **refresh-after-send** (payments-durability invariant, §K1) | +| DashPayProfileView (188) | `DashPayProfileScreen` | own profile display/edit + auto-accept QR render (ZXing — already a dependency) | +| IgnoredContactsView (181) | `IgnoredContactsScreen` | unignore | +| HiddenContactsView (240) | `HiddenContactsScreen` | unhide | +| DashPayContactMeta (183) | `DashPayContactMeta.kt` | meta store (UserDefaults → SharedPreferences/DataStore; plaintext is parity — iOS documents UserDefaults as "the honest backing" for device-local data), display-name precedence, avatar composable | + +QR scan reuses the already-ported `QrScannerScreen`; the auto-accept +scan-to-send path calls `sendContactRequestFromQR`. + +**New dependency:** Coil (`coil-compose`) for avatar loading — not +currently in `libs.versions.toml`; flagged here because it is the plan's +only new third-party dependency. (ZXing is already present.) + +Parity bookkeeping: full `PARITY.md` DashPay rewrite — a `Views/DashPay/` +section with one row per view, corrected totals (interim stale-claims fix +already landed in K1). + +**Gate:** `:app:assembleDebug` + Compose UI tests mirroring +`DashPayTabUITests.swift`; manual UAT next to the iOS simulator per +`QA_TESTCASES_SPEC.md` flows, including the end-to-end +send→accept→pay testnet run. + +## 5. Interfaces & data flow (summary) + +``` +Compose UI ── StateFlow/Room Flow ── PlatformWalletManager / Dashpay.kt + │ │ (thin, marshal-only) + │ DashpayNative.kt (external fun) + │ │ JNI + ▼ rs-unified-sdk-jni/src/dashpay.rs + Room (Dashpay* entities) │ rlib call + ▲ rs-platform-wallet-ffi (C ABI) + │ persistence callbacks │ + └── NativePersistenceBridge ◄── rs-platform-wallet (all logic) +``` + +- Writes require two callback handles: identity signer (`signer.rs`) and + mnemonic resolver (`mnemonic.rs`) — both exist; DashPay adds no new + callback *types*, only new call sites. Handles are kept strongly + referenced for the duration of each call (GC hazard). +- Contact profiles ride the identity persist/restore callback path + (with tombstone-delete semantics); payments are pull-persisted and + array-restored. The deferred contact-crypto queue is deliberately + not persisted (in-memory + sweep self-heal, both platforms). +- Cold-start contract: `loadPersistedWallets` → per-wallet best-effort + unlock (verify → drain) → sync service start → recurring sweep. +- Threading: FFI calls on `Dispatchers.IO`; Rust→Kotlin callbacks attach + as JVM daemon threads; persistence-handler read-modify-writes run in + Room transactions (callback threads race UI-triggered refreshes + otherwise). + +## 6. Failure modes / risks + +- **Restore-path marshaling bugs** corrupt Rust wallet state on load. + Guard: the K1 instrumented round-trip (real native lib) — JVM tests + cannot see this code. +- **Contact-profile staleness:** upsert-only persist misses tombstones → + stale names/avatars forever. Guard: `is_present=false` delete test (K1). +- **Payment loss on process death:** `syncNow` does not persist payments. + Guard: refresh-after-send + kill/relaunch test (K1/K3). +- **Never-draining wallets:** unlock not wired into load → contacts stuck + pending after every restart. Guard: unlock topology spec (§K2) + + restore→unlock instrumented test. +- **Background drain vs biometric gate:** auth-expired signing during + drain must requeue, not fail silently. Guard: auth-expired test (K2). +- **GC vs callback lifetime** during long drains: strong refs on + signer/resolver bridges per call site; drain stress test. +- **Sync-service leak across network switch:** manager-owned lifecycle, + disposed on `WalletManagerStore` swap; double-start test. UI-side: + identity-picker reset on network change. +- **JNI descriptor lockstep** on the identity persist callback (§4 note): + Rust descriptor + Kotlin signature in one commit. +- **Room migration:** additive-only v3; migration test against `2.json`. +- **Build env:** exFAT gotcha (`build_android.sh` sparse image), NDK r28+, + 16 KB alignment — K1 ends with `./build_android.sh --verify` passing. + +## 7. Test plan + +1. **Instrumented (`connectedDebugAndroidTest`, CI emulator)** — the + load-bearing tier: extend `WalletManagerRoundTripTest` with the DashPay + persist→wipe→restore→re-read round-trip (payments incl. memo/direction/ + status, contact-profile tombstone, ignored senders); K1 getters read + restore-injected fixtures; K2 sync-service lifecycle, unlock + state machine (wrong-seed → seedMismatch; auth-expired drain). +2. **JVM unit (`:sdk:testDebugUnitTest`)** — handler↔Room mapping only + (explicitly *not* the marshaling tier): contact-profile + upsert/delete/backoff, payment row mapping, Room v2→v3 migration. +3. **Compose UI tests** — port `DashPayTabUITests.swift` flows using the + shared testTags (tab presence, add-contact form, requests accept path + with a fake bridge). +4. **Testnet opt-in (`-Ptestnet=true`)** — K1 entry gate: existing + FriendsScreen send→accept→ignore revalidation. K3 exit: end-to-end + send→accept→pay between two fixture identities, mirroring iOS UAT. +5. **CI** — existing `kotlin-sdk-build.yml` runs tiers 1–3. + +## 8. Out of scope + +- Invitations (SPEC.md Milestone 5) — not implemented on iOS either. +- The 24 unconsumed FFI functions listed in §2 (and any new handle-wrapper + types for them). +- `managed_identity_get_contested_dpns_names` — its consumers + (SelectMainName / WalletMemoryExplorer / IdentityDetail) are outside + `Views/DashPay/`; it belongs to the existing non-DashPay PARITY-partial + bucket, not this migration. +- Identity-key breadcrumb backfill (justified in §K2 — no pre-breadcrumb + Android installs can exist). +- Migrating the 17 pre-existing DashPay JNI exports out of `tokens.rs`. +- The 5 non-DashPay `TransitionDetailView` FFI gaps and other PARITY + "partial" items. +- Any change to Rust crates other than `rs-unified-sdk-jni` (a genuine + composite gap, if found, becomes its own reviewed `rs-platform-wallet-ffi` + change). + +## 9. Decisions taken in this spec (previously open) + +- **One PR per milestone** — the milestones carry independent gates by + design; review units should match. +- **Sync lifecycle: manager-owned, not process-lifecycle-gated** (§K2). +- **Poll (1 Hz, change-gated), not events, for sync/unlock status** (§K2). +- **Coil added** as the single new dependency (§K3). + +## 10. Open questions (for Ivan) + +1. **Branch/PR strategy:** land the K-milestones as stacked PRs on top of + `feat/kotlin-sdk-and-example-app` (PR #3999 is already ~50k insertions), + or fold into #3999? Recommendation: **stacked PRs**. +2. **Tab restructure confirmation:** mirroring Swift means demoting the + Contracts tab into Settings on Android too. Confirm parity wins over + Android-specific navigation taste. diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index c622c24a1c1..061af19d7f0 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -34,7 +34,7 @@ Status legend: | DocumentTypeDetailsView.swift | ui/contracts/DocumentTypeDetailsScreen.kt · `DocumentTypeDetail` | ported | | DocumentWithPriceView.swift | ui/contracts/DocumentWithPriceScreen.kt · `DocumentWithPrice` | ported — debounced document-id probe (price / owner / ownership badging via `Documents.fetch`), plus the purchase (`DocumentTransactions.purchase` → `platform_wallet_document_purchase`) and owner set-price (`DocumentTransactions.setPrice` → `platform_wallet_document_set_price`) submit flows that live in `PurchaseDocumentView` / the set-price sheet on iOS, hosted as one screen; entries from DocumentsScreen rows ("Price…") and the transition catalog | | DocumentsView.swift | ui/contracts/DocumentsScreen.kt · `Documents` | ported (query role; viewer role in DocumentFieldsScreen; the row-level Purchase… / Set Price… actions drill into DocumentWithPriceScreen; create / replace / delete / transfer stay unbridged — see TransitionDetailView) | -| FriendsView.swift | ui/identity/FriendsScreen.kt · `Friends` | ported — full `loadFriends()` hydration: network sync (`platform_wallet_sync_contact_requests` / `platform_wallet_fetch_sent_contact_requests`) + managed-identity id enumeration (`platform_wallet_get_managed_identity` → `managed_identity_get_{incoming,sent}_contact_request_ids` / `..._established_contact_ids`, Room fallback); send / reject / accept all wired (accept via `managed_identity_get_incoming_contact_request` + the bridged `platform_wallet_accept_contact_request_with_signer`) | +| ~~FriendsView.swift~~ (deleted upstream) | ui/identity/FriendsScreen.kt · `Friends` | **in migration** — the Swift view was deleted by PR #3841 and replaced by the first-class DashPay tab (`Views/DashPay/`, 10 views: DashPayTabView, ContactsView, ContactRequestsView, AddContactView, ContactDetailView, SendDashPayPaymentSheet, DashPayProfileView, IgnoredContactsView, HiddenContactsView, DashPayContactMeta), none of which are ported yet. FriendsScreen still covers a slice (sync / list / send / accept / ignore over the 17 bridged exports, reconciled with the post-#3841 FFI in `2298a2059f`); it is retired and superseded per `docs/dashpay/KOTLIN_MIGRATION_SPEC.md` (milestones K1–K3) | | FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow | | TransferPlatformAddressView.swift (ADDR-02, #3923) | ui/credits/TransferPlatformAddressScreen.kt · `TransferPlatformAddress` | ported — wallet-signed DIP-17 credit transfer via the now-bridged `platform_address_wallet_transfer` (`ManagedPlatformWallet.transferCredits`, AUTO selection, null inputs/fee-strategy); source account + destination (own-wallet / external P2PKH hash) + amount only; gate reads version-locked `minInput`/`minOutput` via `walletPlatformAddressMinAmounts`. Launched from WalletDetailScreen's Platform Credits section | | WithdrawPlatformAddressView.swift (ADDR-04, #3923) | ui/credits/WithdrawPlatformAddressScreen.kt · `WithdrawPlatformAddress` | ported — wallet-signed full-balance DIP-17 withdrawal to a Core L1 address via the now-bridged `platform_address_wallet_withdraw_to_address` (`ManagedPlatformWallet.withdrawCredits`); submit gated on `platform_address_wallet_preflight_withdrawal` (`preflightWithdrawal`, off the main thread on `Dispatchers.IO`), and when the gate refuses, the advisory "why not" from `preflightWithdrawalReason` (`platform_address_wallet_preflight_withdrawal_reason`) renders under the status row; Fibonacci fee-rate picker mirrors `WithdrawalCoreFeeRates`. Launched from WalletDetailScreen's Platform Credits section | @@ -129,9 +129,13 @@ Status legend: ## Totals -- **ported**: 88 (of 90 Swift views) +- **ported**: 87 (of the 90 pre-#3841 Swift views; the FriendsView row above + no longer counts — its Swift source is deleted) - **partial**: 2 (TransitionDetailView — 5 of 23 catalog entries lack backing FFIs: dataContractUpdate, documentCreate/Replace/Delete/Transfer; WalletMemoryExplorerView — asset-lock drill-down summary only) - **deferred**: 0 +- **in migration**: the DashPay tab (10 `Views/DashPay/` views added by + PR #3841) — see `docs/dashpay/KOTLIN_MIGRATION_SPEC.md`; this table gets + its `Views/DashPay/` section when milestone K3 lands Every partial/deferred row names the missing FFI export; the app surfaces the same name in a dialog at the point of use (grep for From 0cb9223bc462960476337badaf763104e460b7ef Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 20:50:04 +0700 Subject: [PATCH 02/12] feat(kotlin-sdk): DashPay persistence completion + read bridges (K1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First milestone of the DashPay Swift→Kotlin migration (docs/dashpay/KOTLIN_MIGRATION_SPEC.md). Persistence — the two missing DashPay stores, mirroring the SwiftData models added by #3841: - DashpayContactProfileEntity (cached contact profiles; persister- projected with is_present=false tombstone DELETEs so a removed on-chain profile can't leave a stale name/avatar) and DashpayPaymentEntity (payment history; pull-persisted via the new refreshDashPayPayments — the sweep reconciles in-memory only). - Room v2→v3 additive migration + exported schema 3.json. - Both bridge directions: identity-entry persist now delivers contact-profile deltas (new onPersistContactProfileDelta bridge slot), and the wallet-list restore path marshals the payments / contact_profiles arrays that were previously null-stubbed in rs-unified-sdk-jni/src/persistence.rs (staging/seal/free extended). Also corrects the stale tombstone doc comments in identity_persistence.rs that contradicted the projection code. Read bridges — new rs-unified-sdk-jni/src/dashpay.rs + DashpayNative.kt (6 exports, JSON-string convention per getDashPayProfile): managed_identity_get_dashpay_payments, platform_wallet_get_contact_profile, managed_identity_get_dashpay_profile, managed_identity_get_dashpay_sync_state, platform_wallet_search_dpns_names (wallet-scoped — the AddContactView call path), platform_wallet_manager_get_account_balances. Kotlin surface: Dashpay.{payments,syncState,getContactProfile,searchDpnsNames}, PlatformWalletManager.{accountBalances,refreshDashPayPayments}. Tests: red-tier coverage per the reviewed spec — 3 new JVM handler tests (profile delta upsert, tombstone delete, payments+profiles restore round-trip; 27/27 green), a new instrumented migration test validating MIGRATION_2_3 against the exported schema, and an instrumented DashPay persist→wipe→restore→re-read round-trip through the real JNI staging/seal/free pipeline (runs on the CI emulator; JVM tests cannot reach that code). build_android.sh --verify not run locally (no NDK on this machine) — covered by kotlin-sdk-build.yml. Co-Authored-By: Claude Fable 5 --- .../3.json | 3842 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 95 + .../wallet/WalletManagerRoundTripTest.kt | 114 + .../dashsdk/ffi/DashpayNative.kt | 75 + .../dashsdk/ffi/NativePersistenceBridge.kt | 81 +- .../dashsdk/persistence/DashDatabase.kt | 68 +- .../PlatformWalletPersistenceHandler.kt | 93 + .../dashsdk/persistence/dao/DashpayDao.kt | 95 + .../entities/DashpayContactProfileEntity.kt | 80 + .../entities/DashpayPaymentEntity.kt | 67 + .../dashfoundation/dashsdk/tokens/Dashpay.kt | 87 + .../dashsdk/wallet/PlatformWalletManager.kt | 75 + .../PlatformWalletPersistenceHandlerTest.kt | 139 + .../src/identity_persistence.rs | 32 +- packages/rs-unified-sdk-jni/src/dashpay.rs | 413 ++ packages/rs-unified-sdk-jni/src/lib.rs | 1 + .../rs-unified-sdk-jni/src/persistence.rs | 380 +- packages/rs-unified-sdk-jni/src/tokens.rs | 6 +- 18 files changed, 5660 insertions(+), 83 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt create mode 100644 packages/rs-unified-sdk-jni/src/dashpay.rs diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json new file mode 100644 index 00000000000..024d845d4a9 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json @@ -0,0 +1,3842 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "9e53afeba6e8c513955ed48bcfb937f4", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `privateKeyKeychainIdentifier` TEXT, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_platform_addresses_addressHash", + "unique": true, + "columnNames": [ + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_addressHash` ON `${TABLE_NAME}` (`addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9e53afeba6e8c513955ed48bcfb937f4')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt new file mode 100644 index 00000000000..4d38717f904 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -0,0 +1,95 @@ +package org.dashfoundation.dashsdk.persistence + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Migration tests over the exported schemas (`sdk/schemas`, wired as + * androidTest assets). Each test creates the database at the FROM + * version, seeds representative rows, runs the migration under test, and + * lets [MigrationTestHelper] validate the migrated schema byte-for-byte + * against the exported TO-version JSON — catching any drift between the + * hand-written SQL in [DashDatabase] and what Room generates. + */ +@RunWith(AndroidJUnit4::class) +class DashDatabaseMigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + DashDatabase::class.java, + ) + + private val dbName = "migration-test.db" + + /** + * v2 → v3 adds the `dashpay_contact_profiles` and `dashpay_payments` + * tables (additive — no reshapes). Pre-existing v2 data must survive + * untouched and the new tables must accept rows keyed by their + * compound PKs. + */ + @Test + fun migrate2To3AddsContactProfileAndPaymentTables() { + helper.createDatabase(dbName, 2).apply { + // Seed a v2 wallet + identity so FK targets exist post-migration. + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + execSQL( + "INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " + + "createdAt, lastUpdated, networkRaw, identityIndex) " + + "VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 3, true, DashDatabase.MIGRATION_2_3) + + // Pre-existing rows survived. + db.query("SELECT COUNT(*) FROM identities").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + // The new tables accept rows under their compound keys. + db.execSQL( + "INSERT INTO dashpay_contact_profiles (networkRaw, ownerIdentityId, " + + "contactIdentityId, checkedAtMs, createdAt, lastUpdated) " + + "VALUES (1, x'0A', x'0B', 123, 0, 0)", + ) + db.execSQL( + "INSERT INTO dashpay_payments (networkRaw, ownerIdentityId, " + + "counterpartyIdentityId, amountDuffs, directionRaw, statusRaw, txid, " + + "createdAt, lastUpdated) VALUES (1, x'0A', x'0B', 5, 0, 1, 'ab', 0, 0)", + ) + db.query("SELECT COUNT(*) FROM dashpay_contact_profiles").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + db.query("SELECT COUNT(*) FROM dashpay_payments").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + db.close() + } + + /** The full chain from v1 must also land on a valid v3 schema. */ + @Test + fun migrateAllTheWayFrom1() { + helper.createDatabase(dbName, 1).close() + helper.runMigrationsAndValidate( + dbName, + 3, + true, + DashDatabase.MIGRATION_1_2, + DashDatabase.MIGRATION_2_3, + ).close() + } +} diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt index a96f8e6bb9f..86321783bd2 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt @@ -100,4 +100,118 @@ class WalletManagerRoundTripTest { assertTrue(walletId.contentEquals(match!!.walletId)) } } + + /** + * DashPay persist→wipe→restore→re-read round-trip through the REAL + * native marshaling (the staging/seal/free pipeline in + * `rs-unified-sdk-jni/src/persistence.rs` that JVM/Robolectric tests + * can never reach): inject payment / contact-profile / ignored-sender + * rows into Room exactly as the persist paths would land them, reload + * the wallet on a fresh manager (the load path marshals the rows into + * Rust wallet state), then read them back out through the DashPay FFI + * getters and assert field equality. Wrong lengths / nulls / + * double-frees in the restore marshaling fail here, not at UAT. + */ + @Test + fun dashPayRestoreRoundTripsPaymentsContactProfilesAndSyncState() = runBlocking { + val walletId: ByteArray + val identityId = ByteArray(32) { 42 } + val contactId = ByteArray(32) { 43 } + val mutedId = ByteArray(32) { 44 } + val txid = "ab".repeat(32) + + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = manager.createWallet( + mnemonic = testMnemonic, + name = "dashpay-round-trip", + createDefaultAccounts = true, + ).walletId + + // Fixture rows, shaped exactly as the persist paths land them: + // an identity owned by the wallet, one payment (Sent + memo — + // the class the reconcile sweep can NOT re-derive), one cached + // contact profile, one ignored sender. + db.identityDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.IdentityEntity( + identityId = identityId, + balance = 1_000, + revision = 1, + networkRaw = Network.TESTNET.ffiValue, + walletId = walletId, + identityIndex = 0, + ), + ) + db.dashpayDao().upsertPayments( + listOf( + org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity( + networkRaw = Network.TESTNET.ffiValue, + ownerIdentityId = identityId, + counterpartyIdentityId = contactId, + amountDuffs = 777_000, + directionRaw = 0, // Sent + statusRaw = 1, // Confirmed + txid = txid, + memo = "round trip", + ), + ), + ) + db.dashpayDao().upsertContactProfile( + org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity( + networkRaw = Network.TESTNET.ffiValue, + ownerIdentityId = identityId, + contactIdentityId = contactId, + displayName = "Bob", + publicMessage = "yo", + avatarUrl = "https://x/bob.png", + avatarHash = ByteArray(32) { 23 }, + avatarFingerprint = null, + checkedAtMs = 1_700_000_111_000, + ), + ) + db.dashpayDao().upsertIgnoredSender( + org.dashfoundation.dashsdk.persistence.entities.DashpayIgnoredSenderEntity( + networkRaw = Network.TESTNET.ffiValue, + ownerIdentityId = identityId, + ignoredSenderId = mutedId, + ), + ) + } + + // Fresh manager: the load path drives the JNI restore marshaling + // (build → seal → Rust restore folds → free) for real. + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { reloaded -> + reloaded.loadPersistedWallets() + val managed = reloaded.wallet(forWalletId = walletId) + assertNotNull("wallet restored", managed) + + val paymentsJson = managed!!.dashpay.payments(identityId) + assertNotNull("payments restored into Rust state", paymentsJson) + val payments = org.json.JSONArray(paymentsJson!!) + assertEquals(1, payments.length()) + val payment = payments.getJSONObject(0) + assertEquals(txid, payment.getString("txid")) + assertEquals(contactId.joinToString("") { "%02x".format(it) }, payment.getString("counterpartyId")) + assertEquals(777_000L, payment.getLong("amountDuffs")) + assertEquals(0, payment.getInt("direction")) + assertEquals(1, payment.getInt("status")) + assertEquals("round trip", payment.getString("memo")) + + val profileJson = managed.dashpay.getContactProfile(identityId, contactId) + assertNotNull("contact profile restored into Rust cache", profileJson) + val profile = org.json.JSONObject(profileJson!!) + assertEquals("Bob", profile.getString("displayName")) + assertEquals("yo", profile.getString("publicMessage")) + assertEquals("https://x/bob.png", profile.getString("avatarUrl")) + assertEquals("17".repeat(32), profile.getString("avatarHash")) // 23 = 0x17 + assertTrue("fingerprint stays absent", !profile.has("avatarFingerprint")) + + val stateJson = managed.dashpay.syncState(identityId) + assertNotNull("sync state readable", stateJson) + val state = org.json.JSONObject(stateJson!!) + assertEquals(1, state.getInt("dashpayPayments")) + assertEquals(1, state.getInt("contactProfiles")) + assertEquals(1, state.getInt("presentContactProfiles")) + assertEquals(1, state.getInt("ignoredSenders")) + } + } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt new file mode 100644 index 00000000000..2f127d39744 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -0,0 +1,75 @@ +package org.dashfoundation.dashsdk.ffi + +/** + * JNI bindings for the DashPay read surface added by the iOS DashPay + * completion (upstream #3841) — payment history, cached contact + * profiles, per-identity sync state, wallet-scoped DPNS search and + * per-account wallet balances. Rust side: + * `rs-unified-sdk-jni/src/dashpay.rs`. The pre-existing DashPay + * send/accept/ignore/sync pipeline lives in [TokensNative]. + * + * Read results are compact JSON strings (the + * [TokensNative.getDashPayProfile] precedent — parsing happens + * Kotlin-side); 32-byte ids inside the JSON are lower-hex. Errors throw + * [DashSDKException]. + */ +internal object DashpayNative { + + /** + * DashPay payment history off a managed-identity handle. Returns a + * JSON array — per payment: `txid`, `counterpartyId` (hex), + * `amountDuffs`, `direction` (0 Sent, 1 Received), `status` + * (0 Pending, 1 Confirmed, 2 Failed), optional `memo`. + * ← Swift `ManagedIdentity.getDashPayPayments()`. + */ + external fun managedIdentityDashPayPayments(identityHandle: Long): String? + + /** + * Cached profile of [contactIdentityId] as seen by + * [ownerIdentityId], or null when none is cached. Same JSON shape as + * [TokensNative.getDashPayProfile]. + * ← Swift `ManagedPlatformWallet.getContactProfile(owner:contact:)`. + */ + external fun getContactProfile( + walletHandle: Long, + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ): String? + + /** + * The managed identity's own cached DashPay profile off its handle, + * or null. ← Swift `ManagedIdentity.getDashPayProfile()`. + */ + external fun managedIdentityDashPayProfile(identityHandle: Long): String? + + /** + * DashPay sync state off a managed-identity handle: JSON object of + * collection counts (`establishedContacts`, `incomingRequests`, + * `sentRequests`, `ignoredSenders`, `contactProfiles`, + * `presentContactProfiles`, `dashpayPayments`, `hasDashpayProfile`) + * plus the optional `highWaterReceivedMs` / `highWaterSentMs` + * cursors (keys absent when unset). + * ← Swift `ManagedIdentity.getDashPaySyncState()`. + */ + external fun managedIdentityDashPaySyncState(identityHandle: Long): String? + + /** + * Live DPNS prefix search against Platform, wallet-scoped (the call + * path iOS `AddContactView` drives — distinct from the + * SDK-handle-scoped `QueriesNative.dpnsSearch`). Returns a JSON + * array of `{"label":…,"identityId":…hex}`; `limit` 0 = no limit. + * Blocking (network). ← Swift + * `ManagedPlatformWallet.searchDpnsNames(prefix:limit:)`. + */ + external fun searchDpnsNames(walletHandle: Long, prefix: String, limit: Int): String? + + /** + * Per-account balance snapshot for [walletId] off the manager + * handle. Returns a JSON array — per account: `typeTag`, + * `standardTag`, `index`, `registrationIndex`, `keyClass`, + * `userIdentityId` / `friendIdentityId` (hex, all-zero when unset), + * `confirmed`, `unconfirmed`, `immature`, `locked`, `keysUsed`, + * `keysTotal`. ← Swift `PlatformWalletManager.accountBalances(for:)`. + */ + external fun walletManagerAccountBalances(managerHandle: Long, walletId: ByteArray): String? +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 1c48e58819c..032a5837837 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -397,6 +397,35 @@ abstract class NativePersistenceBridge { isIgnored: Boolean, ): Int = 0 + /** + * One `ContactProfileRowFFI` delta riding an identity upsert + * (`IdentityEntryFFI.contact_profiles`). Descriptor + * `([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;J)I`. + * + * [isPresent] `true` ⇒ upsert the cached contact-profile row for + * `(ownerId, contactId)`; `false` ⇒ tombstone — the contact removed + * their on-chain profile and any persisted row must be DELETED (an + * upsert-only pipeline would show the stale name/avatar forever). + * Gate [avatarHash] / [avatarFingerprint] on their paired `_present` + * flags — all-zero is a valid hash value. + */ + @Suppress("LongParameterList") + open fun onPersistContactProfileDelta( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isPresent: Boolean, + displayName: String?, + bio: String?, + avatarUrl: String?, + avatarHash: ByteArray, + avatarHashPresent: Boolean, + avatarFingerprint: ByteArray, + avatarFingerprintPresent: Boolean, + publicMessage: String?, + checkedAtMs: Long, + ): Int = 0 + // ── Asset locks ─────────────────────────────────────────────────── /** One `AssetLockEntryFFI` upsert. Descriptor `([B[B[BIBIJB[B)I`. */ @@ -619,10 +648,7 @@ class AccountSpecData( * One flat identity-restore row — mirror of `IdentityRestoreEntryFFI`. * * DPNS names ride separately (empty for this pass; the Rust trampoline - * passes null/0), as do the DashPay payment-history and cached - * contact-profile arrays (Kotlin has no persist source for those yet — - * the trampoline stages them null/0 and the post-load sweeps rebuild - * them). `status` uses the `IdentityStatus` discriminant encoding + * passes null/0). `status` uses the `IdentityStatus` discriminant encoding * (0 Unknown, 1 PendingCreation, 2 Active, 3 FailedCreation, 4 NotFound). */ class IdentityRestoreData( @@ -649,6 +675,53 @@ class IdentityRestoreData( * sender resurfaces after every relaunch. */ @JvmField val ignoredSenders: Array, + /** + * DashPay payment-history rows to rehydrate the Rust + * `dashpay_payments` map at load — without this *Sent* entries (and + * their user-entered memos) vanish on every relaunch; the reconcile + * sweep can only re-derive *Received* entries from UTXOs. + */ + @JvmField val payments: Array, + /** + * Cached contact-profile rows (present profiles only — tombstones + * delete the Room row at persist time) to rehydrate the Rust + * `contact_profiles` map at load — without this the contacts UI + * shows raw identity ids after relaunch until the next profile sweep + * re-fetches every contact. + */ + @JvmField val contactProfiles: Array, +) + +/** + * One flat DashPay payment-history restore row — mirror of + * `PaymentRestoreEntryFFI`. `directionRaw`: 0 Sent, 1 Received; + * `statusRaw`: 0 Pending, 1 Confirmed, 2 Failed. [memo] null mirrors the + * source `Option` being `None`. + */ +class PaymentRestoreData( + @JvmField val txid: String, + @JvmField val counterpartyId: ByteArray, + @JvmField val amountDuffs: Long, + @JvmField val directionRaw: Byte, + @JvmField val statusRaw: Byte, + @JvmField val memo: String?, +) + +/** + * One flat cached contact-profile restore row — mirror of + * `ContactProfileRestoreEntryFFI`. [avatarHash] / [avatarFingerprint] + * are null when absent (the trampoline derives the FFI `_present` flags + * from nullability + length: 32 / 8 bytes respectively). + */ +class ContactProfileRestoreData( + @JvmField val contactId: ByteArray, + @JvmField val displayName: String?, + @JvmField val bio: String?, + @JvmField val avatarUrl: String?, + @JvmField val avatarHash: ByteArray?, + @JvmField val avatarFingerprint: ByteArray?, + @JvmField val publicMessage: String?, + @JvmField val checkedAtMs: Long, ) /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 7ae51d9b294..2143e262ccf 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -28,8 +28,10 @@ import org.dashfoundation.dashsdk.persistence.dao.WalletManagerMetadataDao import org.dashfoundation.dashsdk.persistence.entities.AccountEntity import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayIgnoredSenderEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity import org.dashfoundation.dashsdk.persistence.entities.DocumentEntity @@ -70,9 +72,16 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * established-row metadata columns on `dashpay_contact_requests` * (`paymentChannelBroken` / `contactAlias` / `contactNote` / * `contactHidden` / `contactAccountLabel` / `contactAcceptedAccounts`). + * + * Version 3 (DashPay contact-profile cache + payment history): adds the + * `dashpay_contact_profiles` table (mirror of the Rust + * `contact_profiles` map — persister-projected with tombstone deletes, + * restored at load) and the `dashpay_payments` table (mirror of the Rust + * `dashpay_payments` map — pull-persisted via `refreshDashPayPayments`, + * restored at load). */ @Database( - version = 2, + version = 3, exportSchema = true, entities = [ WalletEntity::class, @@ -87,6 +96,8 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti DashpayProfileEntity::class, DashpayContactRequestEntity::class, DashpayIgnoredSenderEntity::class, + DashpayContactProfileEntity::class, + DashpayPaymentEntity::class, DataContractEntity::class, DocumentTypeEntity::class, DocumentEntity::class, @@ -176,6 +187,59 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v2 → v3: the DashPay contact-profile cache + payment-history + * tables. Purely additive. SQL mirrors the exported + * `schemas/.../3.json` `createSql` exactly (column order = entity + * field order). + */ + val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `dashpay_contact_profiles` (" + + "`networkRaw` INTEGER NOT NULL, " + + "`ownerIdentityId` BLOB NOT NULL, " + + "`contactIdentityId` BLOB NOT NULL, " + + "`displayName` TEXT, " + + "`publicMessage` TEXT, " + + "`bio` TEXT, " + + "`avatarUrl` TEXT, " + + "`avatarHash` BLOB, " + + "`avatarFingerprint` BLOB, " + + "`checkedAtMs` INTEGER NOT NULL, " + + "`createdAt` INTEGER NOT NULL, " + + "`lastUpdated` INTEGER NOT NULL, " + + "PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), " + + "FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` " + + "ON `dashpay_contact_profiles` (`ownerIdentityId`)", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `dashpay_payments` (" + + "`networkRaw` INTEGER NOT NULL, " + + "`ownerIdentityId` BLOB NOT NULL, " + + "`counterpartyIdentityId` BLOB NOT NULL, " + + "`amountDuffs` INTEGER NOT NULL, " + + "`directionRaw` INTEGER NOT NULL, " + + "`statusRaw` INTEGER NOT NULL, " + + "`txid` TEXT NOT NULL, " + + "`memo` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`lastUpdated` INTEGER NOT NULL, " + + "PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), " + + "FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` " + + "ON `dashpay_payments` (`ownerIdentityId`)", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -184,7 +248,7 @@ abstract class DashDatabase : RoomDatabase() { */ fun create(context: Context): DashDatabase = Room.databaseBuilder(context, DashDatabase::class.java, DATABASE_NAME) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() /** In-memory variant for tests. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index c2e11382ddb..d9506b10506 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -7,10 +7,12 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.dashfoundation.dashsdk.ffi.AccountSpecData +import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData import org.dashfoundation.dashsdk.ffi.ContactRequestRestoreData import org.dashfoundation.dashsdk.ffi.CoreTxRecordData import org.dashfoundation.dashsdk.ffi.IdentityKeyRestoreData import org.dashfoundation.dashsdk.ffi.IdentityRestoreData +import org.dashfoundation.dashsdk.ffi.PaymentRestoreData import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.PlatformAddressBalanceRestoreData import org.dashfoundation.dashsdk.ffi.ShieldedActivityData @@ -21,6 +23,7 @@ import org.dashfoundation.dashsdk.ffi.WalletRestoreData import org.dashfoundation.dashsdk.persistence.entities.AccountEntity import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayIgnoredSenderEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity @@ -897,6 +900,57 @@ class PlatformWalletPersistenceHandler( 0 } + /** + * One cached contact-profile delta riding an identity upsert (mirror + * of the Swift `upsertDashpayContactProfiles` loop). A present entry + * upserts the [DashpayContactProfileEntity] row; a tombstone + * (`isPresent == false` — the contact removed their on-chain + * profile) DELETEs it, so a stale name/avatar can't outlive the + * on-chain deletion. + */ + @Suppress("LongParameterList") + override fun onPersistContactProfileDelta( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isPresent: Boolean, + displayName: String?, + bio: String?, + avatarUrl: String?, + avatarHash: ByteArray, + avatarHashPresent: Boolean, + avatarFingerprint: ByteArray, + avatarFingerprintPresent: Boolean, + publicMessage: String?, + checkedAtMs: Long, + ): Int = guarded { + stage(walletId) { db -> + // Owner identity must exist (networkRaw is read off it); skip + // silently otherwise — replayed next round. + val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + if (isPresent) { + db.dashpayDao().upsertContactProfile( + DashpayContactProfileEntity( + networkRaw = owner.networkRaw, + ownerIdentityId = ownerId, + contactIdentityId = contactId, + displayName = displayName, + publicMessage = publicMessage, + bio = bio, + avatarUrl = avatarUrl, + avatarHash = avatarHash.takeIf { avatarHashPresent }, + avatarFingerprint = avatarFingerprint.takeIf { avatarFingerprintPresent }, + checkedAtMs = checkedAtMs, + lastUpdated = now(), + ), + ) + } else { + db.dashpayDao().deleteContactProfile(owner.networkRaw, ownerId, contactId) + } + } + 0 + } + override fun onPersistContactRemovalSent( walletId: ByteArray, ownerId: ByteArray, @@ -1361,6 +1415,43 @@ class PlatformWalletPersistenceHandler( .map { it.ignoredSenderId } .filter { it.size == 32 } .toTypedArray() + // Payment history — restores the Rust `dashpay_payments` map + // so Sent entries (and their memos) survive relaunch; the + // reconcile sweep can only re-derive Received entries. Rows + // reach Room solely via `refreshDashPayPayments` (the sweep + // reconciles in-memory without persisting). + val paymentRows = database.dashpayDao() + .getPaymentsByOwner(idRow.identityId) + .filter { it.counterpartyIdentityId.size == 32 } + .map { p -> + PaymentRestoreData( + txid = p.txid, + counterpartyId = p.counterpartyIdentityId, + amountDuffs = p.amountDuffs, + directionRaw = p.directionRaw.toByte(), + statusRaw = p.statusRaw.toByte(), + memo = p.memo, + ) + }.toTypedArray() + // Cached contact profiles (present only — tombstones deleted + // the row at persist time) — restores the Rust + // `contact_profiles` map so the contacts UI shows names + + // avatars immediately after relaunch. + val contactProfileRows = database.dashpayDao() + .getContactProfilesByOwner(idRow.identityId) + .filter { it.contactIdentityId.size == 32 } + .map { cp -> + ContactProfileRestoreData( + contactId = cp.contactIdentityId, + displayName = cp.displayName, + bio = cp.bio, + avatarUrl = cp.avatarUrl, + avatarHash = cp.avatarHash, + avatarFingerprint = cp.avatarFingerprint, + publicMessage = cp.publicMessage, + checkedAtMs = cp.checkedAtMs, + ) + }.toTypedArray() IdentityRestoreData( identityId = idRow.identityId, balance = idRow.balance, @@ -1373,6 +1464,8 @@ class PlatformWalletPersistenceHandler( keys = keyRows, contacts = contactRows, ignoredSenders = ignoredRows, + payments = paymentRows, + contactProfiles = contactProfileRows, ) }.toTypedArray() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt index 5ccda5fb8d1..1948639fd38 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt @@ -4,8 +4,10 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow +import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayIgnoredSenderEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity /** @@ -132,4 +134,97 @@ interface DashpayDao { /** StorageExplorer network-scoped row count. */ @Query("SELECT COUNT(*) FROM dashpay_contact_requests WHERE networkRaw = :networkRaw") fun countContactRequestsByNetwork(networkRaw: Int): Flow + + // MARK: Cached contact profiles + + /** Mirror of `PersistentDashpayContactProfile.predicate(ownerIdentityId:)`. */ + @Query("SELECT * FROM dashpay_contact_profiles WHERE ownerIdentityId = :ownerIdentityId") + fun observeContactProfiles( + ownerIdentityId: ByteArray, + ): Flow> + + /** Mirror of `predicate(ownerIdentityId:contactIdentityId:)`. */ + @Query( + "SELECT * FROM dashpay_contact_profiles WHERE ownerIdentityId = :ownerIdentityId " + + "AND contactIdentityId = :contactIdentityId" + ) + fun observeContactProfile( + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ): Flow + + /** Persister upsert (`is_present == true` changeset entry). */ + @Upsert + suspend fun upsertContactProfile(profile: DashpayContactProfileEntity) + + /** + * Persister tombstone (`is_present == false` changeset entry): the + * contact removed their on-chain profile, so the cached row must go — + * an upsert-only pipeline would leave a stale name/avatar forever. + */ + @Query( + "DELETE FROM dashpay_contact_profiles WHERE networkRaw = :networkRaw " + + "AND ownerIdentityId = :ownerIdentityId " + + "AND contactIdentityId = :contactIdentityId" + ) + suspend fun deleteContactProfile( + networkRaw: Int, + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ) + + /** Restore-path read: every cached contact profile owned by [ownerIdentityId]. */ + @Query("SELECT * FROM dashpay_contact_profiles WHERE ownerIdentityId = :ownerIdentityId") + suspend fun getContactProfilesByOwner( + ownerIdentityId: ByteArray, + ): List + + @Query("DELETE FROM dashpay_contact_profiles") + suspend fun deleteAllContactProfiles() + + /** StorageExplorer row count. */ + @Query("SELECT COUNT(*) FROM dashpay_contact_profiles") + fun countContactProfiles(): Flow + + /** StorageExplorer network-scoped row count. */ + @Query("SELECT COUNT(*) FROM dashpay_contact_profiles WHERE networkRaw = :networkRaw") + fun countContactProfilesByNetwork(networkRaw: Int): Flow + + // MARK: Payments (pull-persisted; see DashpayPaymentEntity KDoc) + + /** Mirror of `PersistentDashpayPayment.predicate(ownerIdentityId:)`. */ + @Query("SELECT * FROM dashpay_payments WHERE ownerIdentityId = :ownerIdentityId") + fun observePayments(ownerIdentityId: ByteArray): Flow> + + /** + * Mirror of `predicate(ownerIdentityId:counterpartyIdentityId:)` — + * the payment list on the contact-detail screen shows only the + * history with that one contact. + */ + @Query( + "SELECT * FROM dashpay_payments WHERE ownerIdentityId = :ownerIdentityId " + + "AND counterpartyIdentityId = :counterpartyIdentityId" + ) + fun observePayments( + ownerIdentityId: ByteArray, + counterpartyIdentityId: ByteArray, + ): Flow> + + @Upsert + suspend fun upsertPayments(payments: List) + + /** Restore-path read: every payment row owned by [ownerIdentityId]. */ + @Query("SELECT * FROM dashpay_payments WHERE ownerIdentityId = :ownerIdentityId") + suspend fun getPaymentsByOwner(ownerIdentityId: ByteArray): List + + @Query("DELETE FROM dashpay_payments") + suspend fun deleteAllPayments() + + /** StorageExplorer row count. */ + @Query("SELECT COUNT(*) FROM dashpay_payments") + fun countPayments(): Flow + + /** StorageExplorer network-scoped row count. */ + @Query("SELECT COUNT(*) FROM dashpay_payments WHERE networkRaw = :networkRaw") + fun countPaymentsByNetwork(networkRaw: Int): Flow } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt new file mode 100644 index 00000000000..bb72b816457 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt @@ -0,0 +1,80 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import java.util.Date + +/** + * Port of `PersistentDashpayContactProfile.swift` — one cached DashPay + * **contact** profile, a mirror of one entry in the Rust-side + * `contact_profiles` map on a `ManagedIdentity`, one row per + * `(network, owner, contact)` triple. + * + * Distinct from [DashpayProfileEntity], which is the owner's *own* + * profile: this row is a *contact's* public profile, cached so the + * requests / contacts UI can show a display name + avatar without + * re-fetching on every launch. It holds **only the public profile + * fields** parsed from the on-chain `profile` document; it must never + * receive anything derived from the encrypted `contactInfo` path. + * + * Populated by the platform-wallet persister callback whenever an + * `IdentityEntry.contact_profiles` entry rides on the FFI changeset. A + * present profile (`is_present == true`) upserts this row; a + * confirmed-absent entry (`is_present == false`) DELETEs it, so a + * contact who removed their on-chain profile can't leave a stale + * name/avatar behind. Read back at load to rebuild the Rust + * `contact_profiles` map so the cache survives relaunch. + * + * [ownerIdentityId] materializes the NON-optional `owner` relationship + * with CASCADE (Swift `PersistentIdentity.contactProfiles` declares + * `.cascade`). + */ +@Entity( + tableName = "dashpay_contact_profiles", + primaryKeys = ["networkRaw", "ownerIdentityId", "contactIdentityId"], + indices = [Index(value = ["ownerIdentityId"])], + foreignKeys = [ + ForeignKey( + entity = IdentityEntity::class, + parentColumns = ["identityId"], + childColumns = ["ownerIdentityId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class DashpayContactProfileEntity( + /** `Network.rawValue`; Swift `UInt32` → [Int]. */ + val networkRaw: Int, + /** Owning (wallet-managed) identity's 32-byte id. */ + val ownerIdentityId: ByteArray, + /** The contact's 32-byte identity id — the `contact_profiles` map key. */ + val contactIdentityId: ByteArray, + /** `displayName` field on the contact's `profile` document. */ + val displayName: String? = null, + /** `publicMessage` field on the contact's `profile` document. */ + val publicMessage: String? = null, + /** Reserved forwards-compat slot (not in the v3 contract). */ + val bio: String? = null, + /** + * `avatarUrl` field — URL the consumer fetches + caches locally; the + * binary asset itself is never persisted. Untrusted public data: the + * Rust side caches and restores it only when it is a bounded + * `https://` URL. + */ + val avatarUrl: String? = null, + /** 32-byte hash of the avatar binary. */ + val avatarHash: ByteArray? = null, + /** 8-byte perceptual hash. */ + val avatarFingerprint: ByteArray? = null, + /** + * Wall-clock ms of the last fetch attempt on the Rust side + * (`ContactProfileEntry.checked_at_ms`) — drives the self-heal + * backoff. Round-tripped verbatim so the restored cache keeps the + * same re-query schedule it had before relaunch. Swift `UInt64` → + * [Long]. + */ + val checkedAtMs: Long, + val createdAt: Date = Date(), + val lastUpdated: Date = Date(), +) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt new file mode 100644 index 00000000000..81112e29561 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt @@ -0,0 +1,67 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import java.util.Date + +/** + * Port of `PersistentDashpayPayment.swift` — one DashPay payment-history + * entry, a mirror of one entry in the Rust-side `dashpay_payments` map on + * a `ManagedIdentity`, one row per `(network, owner, txid)` triple. + * + * Unlike the contact-request rows this entity is **not** populated by the + * persister callback — the Rust persister doesn't project payment + * history. Rows are refreshed on demand from the + * `managed_identity_get_dashpay_payments` FFI getter via + * `PlatformWalletManager.refreshDashPayPayments`, which upserts here so + * the UI can observe payments reactively. That refresh is the ONLY path + * by which payment rows become durable; the recurring DashPay sweep + * reconciles payments in-memory without persisting them. + * + * The source `PaymentEntry` carries no timestamp field (history is keyed + * by txid, no wall-clock time recorded), so none is persisted — + * [createdAt] / [lastUpdated] are local row bookkeeping, not payment + * dates. + * + * [ownerIdentityId] materializes the NON-optional `owner` relationship + * with CASCADE (Swift `PersistentIdentity.dashpayPayments` declares + * `.cascade`). + */ +@Entity( + tableName = "dashpay_payments", + primaryKeys = ["networkRaw", "ownerIdentityId", "txid"], + indices = [Index(value = ["ownerIdentityId"])], + foreignKeys = [ + ForeignKey( + entity = IdentityEntity::class, + parentColumns = ["identityId"], + childColumns = ["ownerIdentityId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class DashpayPaymentEntity( + /** `Network.rawValue`; Swift `UInt32` → [Int]. */ + val networkRaw: Int, + /** Owning (wallet-managed) identity's 32-byte id. */ + val ownerIdentityId: ByteArray, + /** + * The other identity in this payment + * (`DashpayPaymentFFI::counterparty_id`). Whether they are the sender + * or the receiver is encoded in [directionRaw]. + */ + val counterpartyIdentityId: ByteArray, + /** Amount in duffs; always positive, [directionRaw] carries the sign. Swift `UInt64` → [Long]. */ + val amountDuffs: Long, + /** Raw `DashPayPaymentDirection` value (0 = sent, 1 = received); Swift `UInt8` → [Int]. */ + val directionRaw: Int, + /** Raw `DashPayPaymentStatus` value (0 = pending, 1 = confirmed, 2 = failed); Swift `UInt8` → [Int]. */ + val statusRaw: Int, + /** Transaction id (hex), the Rust `dashpay_payments` map key. */ + val txid: String, + /** Sender memo, when present; null mirrors the source `Option` being `None`. */ + val memo: String? = null, + val createdAt: Date = Date(), + val lastUpdated: Date = Date(), +) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 030b659fb4a..ae657ace94a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -4,6 +4,7 @@ import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.TokensNative /** @@ -251,6 +252,92 @@ class Dashpay internal constructor(private val walletHandle: Long) { } } + // ── DashPay read surface (upstream #3841 parity) ─────────────────── + // + // JSON-string reads over the managed-identity snapshot, following the + // [getProfile] precedent (parsing happens at the consumer); see + // `DashpayNative` for the field shapes. + + /** + * Read [identityId]'s DashPay payment history as a JSON array string + * (empty array when none). Local read over a managed-identity + * snapshot — no network I/O. This getter is the ONLY durable source + * of payment rows: `PlatformWalletManager.refreshDashPayPayments` + * upserts its result into Room (the recurring sweep reconciles + * payments in-memory without persisting). Returns null when the + * identity isn't managed by this wallet. + * ← Swift `ManagedIdentity.getDashPayPayments()`. + */ + suspend fun payments(identityId: ByteArray): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(identityId) { handle -> + DashpayNative.managedIdentityDashPayPayments(handle) + } + } + } + + /** + * Read [identityId]'s DashPay sync state (collection counts + + * high-water cursors) as a JSON object string, or null when the + * identity isn't managed by this wallet. Local read. + * ← Swift `ManagedIdentity.getDashPaySyncState()`. + */ + suspend fun syncState(identityId: ByteArray): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(identityId) { handle -> + DashpayNative.managedIdentityDashPaySyncState(handle) + } + } + } + + /** + * Read the cached public profile of [contactIdentityId] as seen by + * [ownerIdentityId] (the contact-profile cache the requests/contacts + * UI renders names + avatars from), or null when none is cached. + * Same JSON shape as [getProfile]. Local read. + * ← Swift `ManagedPlatformWallet.getContactProfile(owner:contact:)`. + */ + suspend fun getContactProfile( + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.getContactProfile(walletHandle, ownerIdentityId, contactIdentityId) + } + } + + /** + * Live DPNS prefix search against Platform (wallet-scoped — the call + * path iOS `AddContactView` drives). Returns a JSON array string of + * `{"label":…,"identityId":…hex}`; [limit] 0 means no limit. + * Blocking network call; runs on IO. + * ← Swift `ManagedPlatformWallet.searchDpnsNames(prefix:limit:)`. + */ + suspend fun searchDpnsNames(prefix: String, limit: Int = 10): String? = + withContext(Dispatchers.IO) { + require(limit >= 0) { "limit must be non-negative, got $limit" } + mapNativeErrors { DashpayNative.searchDpnsNames(walletHandle, prefix, limit) } + } + + /** + * Open the managed-identity handle for [identityId], run [block], + * and destroy the handle before returning (the [contacts] / + * [acceptIncomingRequest] discipline). Returns null when the + * identity isn't managed by this wallet. + */ + private inline fun withManagedIdentity( + identityId: ByteArray, + block: (Long) -> T, + ): T? { + val handle = TokensNative.getManagedIdentity(walletHandle, identityId) + if (handle == 0L) return null + return try { + block(handle) + } finally { + TokensNative.managedIdentityDestroy(handle) + } + } + private fun decodeIdBlob(blob: ByteArray?): List { if (blob == null || blob.size < 4) return emptyList() val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index c0807088825..428bcd1fe1c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -19,12 +19,15 @@ import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.Sdk import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.FundingNative import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.persistence.DashDatabase import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.toHex +import org.json.JSONArray import org.dashfoundation.dashsdk.security.BiometricGate import org.dashfoundation.dashsdk.security.IdentityKeyPrivateKeyDeriver import org.dashfoundation.dashsdk.security.KeystoreSigner @@ -518,6 +521,78 @@ class PlatformWalletManager( mapNativeErrors { WalletManagerNative.platformAddressSyncReset(managerHandle) } } + /** + * Per-account balance snapshot for [walletId] as a JSON array string + * (see `DashpayNative.walletManagerAccountBalances` for the row + * shape) — drives the DashPay tab's account balance display. Port of + * Swift `PlatformWalletManager.accountBalances(for:)`. + */ + suspend fun accountBalances(walletId: ByteArray): String? = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.walletManagerAccountBalances(managerHandle, walletId) } + } + + /** + * Refresh the persisted DashPay payment history for one identity: + * one FFI read (`managed_identity_get_dashpay_payments`) + one Room + * pass upserting [DashpayPaymentEntity] rows so the UI can observe + * them reactively. This is the ONLY path by which payment rows + * become durable — the recurring DashPay sweep reconciles payments + * in-memory without persisting them (matching iOS), so callers must + * refresh after a send and when opening a contact's payment history. + * Upsert-only: payment history is append-only, keyed by txid. + * + * Returns the raw payments JSON that was persisted, or null when + * [identityId] isn't managed by the wallet. Port of Swift + * `PlatformWalletManager.refreshDashPayPayments(walletId:identityId:)`. + */ + suspend fun refreshDashPayPayments(walletId: ByteArray, identityId: ByteArray): String? = + withContext(Dispatchers.IO) { + val managed = requireNotNull(wallet(forWalletId = walletId)) { + "no loaded wallet with id ${walletId.toHex()}" + } + val json = managed.dashpay.payments(identityId) ?: return@withContext null + // networkRaw rides on the owner identity row (the persist-path + // convention); when the identity row hasn't landed yet the read + // still succeeds — the rows just aren't persisted this round. + val networkRaw = database.identityDao().getByIdentityId(identityId)?.networkRaw + ?: return@withContext json + val rows = JSONArray(json) + val entities = ArrayList(rows.length()) + for (i in 0 until rows.length()) { + val row = rows.getJSONObject(i) + val txid = row.optString("txid", "") + val counterparty = row.optString("counterpartyId", "").hexToBytesOrNull() + if (txid.isEmpty() || counterparty == null || counterparty.size != 32) continue + entities.add( + DashpayPaymentEntity( + networkRaw = networkRaw, + ownerIdentityId = identityId, + counterpartyIdentityId = counterparty, + amountDuffs = row.optLong("amountDuffs"), + directionRaw = row.optInt("direction"), + statusRaw = row.optInt("status"), + txid = txid, + memo = if (row.has("memo")) row.getString("memo") else null, + ), + ) + } + if (entities.isNotEmpty()) database.dashpayDao().upsertPayments(entities) + json + } + + /** Lower-hex decode; null on odd length, empty, or non-hex characters. */ + private fun String.hexToBytesOrNull(): ByteArray? { + if (length % 2 != 0 || isEmpty()) return null + val out = ByteArray(length / 2) + for (i in out.indices) { + val hi = Character.digit(this[2 * i], 16) + val lo = Character.digit(this[2 * i + 1], 16) + if (hi < 0 || lo < 0) return null + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } + suspend fun startIdentitySync() = withContext(Dispatchers.IO) { mapNativeErrors { WalletManagerNative.identitySyncStart(managerHandle) } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 40c6c08bbf0..3d5893a54ce 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -813,6 +813,145 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(walletId.contentEquals(list[0].walletId)) } + // ── DashPay contact profiles: delta upsert / tombstone, restore ─── + + /** Persist one present contact-profile delta for [contactId] owned by [ownerId]. */ + private suspend fun persistContactProfile(ownerId: ByteArray, contactId: ByteArray) { + handler.onChangesetBegin(walletId) + handler.onPersistContactProfileDelta( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isPresent = true, + displayName = "Bob", + bio = null, + avatarUrl = "https://x/bob.png", + avatarHash = ByteArray(32) { 23 }, + avatarHashPresent = true, + avatarFingerprint = ByteArray(8), + avatarFingerprintPresent = false, + publicMessage = "yo", + checkedAtMs = 1_700_000_111_000, + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun contactProfileDeltaUpsertsPresentRow() = runTest { + // A present `IdentityEntryFFI.contact_profiles` row must land as a + // cached contact-profile row — with the avatar byte fields gated on + // their `_present` flags (all-zero is a valid hash value, so + // nullability must come from the flag, not the bytes). + val ownerId = ByteArray(32) { 22 } + val contactId = ByteArray(32) { 33 } + seedIdentity(ownerId) + persistContactProfile(ownerId, contactId) + + val rows = db.dashpayDao().getContactProfilesByOwner(ownerId) + assertEquals(1, rows.size) + val row = rows[0] + assertEquals(testnet, row.networkRaw) + assertTrue(contactId.contentEquals(row.contactIdentityId)) + assertEquals("Bob", row.displayName) + assertNull(row.bio) + assertEquals("https://x/bob.png", row.avatarUrl) + assertTrue(ByteArray(32) { 23 }.contentEquals(row.avatarHash!!)) + assertNull(row.avatarFingerprint) + assertEquals("yo", row.publicMessage) + assertEquals(1_700_000_111_000, row.checkedAtMs) + } + + @Test + fun contactProfileTombstoneDeletesTheRow() = runTest { + // An `is_present == false` delta means the contact removed their + // on-chain profile: the persisted row must be DELETED. An + // upsert-only pipeline would show the stale name/avatar forever. + val ownerId = ByteArray(32) { 24 } + val contactId = ByteArray(32) { 25 } + seedIdentity(ownerId) + persistContactProfile(ownerId, contactId) + assertEquals(1, db.dashpayDao().getContactProfilesByOwner(ownerId).size) + + handler.onChangesetBegin(walletId) + val code = handler.onPersistContactProfileDelta( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isPresent = false, + displayName = null, + bio = null, + avatarUrl = null, + avatarHash = ByteArray(32), + avatarHashPresent = false, + avatarFingerprint = ByteArray(8), + avatarFingerprintPresent = false, + publicMessage = null, + checkedAtMs = 1_700_000_222_000, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(0, code) + + assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + } + + @Test + fun loadWalletListRoundTripsPaymentsAndContactProfiles() = runTest { + // Relaunch-durability for the two #3841 stores: payments (Sent + // entries + memos are NOT re-derivable from UTXOs — losing them + // here loses them forever) and the contact-profile cache (without + // it the contacts UI shows raw identity ids until the next + // profile sweep re-fetches every contact). + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + val ownerId = ByteArray(32) { 26 } + val contactId = ByteArray(32) { 27 } + seedIdentity(ownerId) + persistContactProfile(ownerId, contactId) + // Payments are pull-persisted (refreshDashPayPayments → DAO); the + // load path reads whatever rows the refresh landed. + db.dashpayDao().upsertPayments( + listOf( + org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity( + networkRaw = testnet, + ownerIdentityId = ownerId, + counterpartyIdentityId = contactId, + amountDuffs = 123_456, + directionRaw = 0, // Sent + statusRaw = 1, // Confirmed + txid = "aa".repeat(32), + memo = "for pizza", + ), + ), + ) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + val identity = list[0].identities.single() + + assertEquals(1, identity.payments.size) + val payment = identity.payments[0] + assertEquals("aa".repeat(32), payment.txid) + assertTrue(contactId.contentEquals(payment.counterpartyId)) + assertEquals(123_456L, payment.amountDuffs) + assertEquals(0.toByte(), payment.directionRaw) + assertEquals(1.toByte(), payment.statusRaw) + assertEquals("for pizza", payment.memo) + + assertEquals(1, identity.contactProfiles.size) + val profile = identity.contactProfiles[0] + assertTrue(contactId.contentEquals(profile.contactId)) + assertEquals("Bob", profile.displayName) + assertNull(profile.bio) + assertEquals("https://x/bob.png", profile.avatarUrl) + assertTrue(ByteArray(32) { 23 }.contentEquals(profile.avatarHash!!)) + assertNull(profile.avatarFingerprint) + assertEquals("yo", profile.publicMessage) + assertEquals(1_700_000_111_000, profile.checkedAtMs) + } + // ── Free-function encoders ──────────────────────────────────────── @Test diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 644d1728229..c1fc96100cf 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -141,15 +141,15 @@ pub struct IdentityEntryFFI { /// [`free_identity_entry_ffi`]. Ignore unless /// [`Self::dashpay_profile_present`] is `true`. pub dashpay_profile_public_message: *const c_char, - /// Heap-allocated array of [`ContactProfileRowFFI`], one per - /// **present** cached contact profile on the underlying - /// [`IdentityEntry::contact_profiles`]. Confirmed-absent entries - /// (`profile: None`, the negative cache) are NOT projected — they - /// rebuild harmlessly on the next sync sweep, so persisting them - /// would only add write churn. Each row owns the same per-string - /// heap allocations the own-profile block does; every string plus - /// the outer boxed slice is released in [`free_identity_entry_ffi`]. - /// `null` when [`Self::contact_profiles_count`] is 0. + /// Heap-allocated array of [`ContactProfileRowFFI`], one per entry + /// of the underlying [`IdentityEntry::contact_profiles`] map — + /// present profiles as full rows, confirmed-absent entries as + /// `is_present == false` tombstone rows instructing the consumer to + /// DELETE its persisted row (see [`ContactProfileRowFFI`]). Each row + /// owns the same per-string heap allocations the own-profile block + /// does; every string plus the outer boxed slice is released in + /// [`free_identity_entry_ffi`]. `null` when + /// [`Self::contact_profiles_count`] is 0. /// /// Distinct from `dashpay_profile_*` above: that block is the /// owner's *own* profile (one per identity); this array is the @@ -572,14 +572,12 @@ fn allocate_dpns_arrays( /// Allocate the [`ContactProfileRowFFI`] array carried on /// [`IdentityEntryFFI`] from the source /// [`IdentityEntry::contact_profiles`] map. Returns `(rows, count)` — -/// both `null`/`0` when no entry carries a **present** profile. -/// -/// **Present profiles only.** Confirmed-absent entries -/// (`ContactProfileEntry::profile == None`, the negative cache) are -/// skipped: they rebuild harmlessly on the next sync sweep, so -/// persisting them would only add write churn (the "persist only on -/// change" discipline). The returned `count` -/// is therefore the number of *present* profiles, not the map length. +/// both `null`/`0` when the map is empty. Every map entry produces a +/// row: present profiles as full rows, confirmed-absent entries +/// (`ContactProfileEntry::profile == None`) as `is_present == false` +/// tombstones that tell the consumer to DELETE its persisted row (a +/// contact who removed their profile must not keep showing a stale +/// name/avatar). `count` is therefore the map length. /// /// `rows` is a `Box<[ContactProfileRowFFI]>` (via [`Box::into_raw`]). /// Each row's four nullable C-strings are [`CString::into_raw`] diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs new file mode 100644 index 00000000000..d8f17bcefc6 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -0,0 +1,413 @@ +//! JNI bridge for the DashPay read surface added by the iOS DashPay +//! completion (upstream #3841): payment history, cached contact profiles, +//! per-identity sync state, wallet-scoped DPNS search and per-account +//! wallet balances. +//! +//! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DashpayNative`, +//! driven by `org.dashfoundation.dashsdk.tokens.Dashpay` and +//! `org.dashfoundation.dashsdk.wallet.PlatformWalletManager`. The 17 +//! pre-existing DashPay exports (send/accept/ignore/sync pipeline) live +//! in [`crate::tokens`]; new DashPay exports land here. +//! +//! ## Result convention +//! +//! Same as [`crate::tokens`]: every platform-wallet call returns +//! `PlatformWalletFFIResult` consumed Rust-side; +//! [`crate::support::take_pwffi_error`] maps failures to a thrown +//! `DashSDKException`. Read results surface as compact JSON strings +//! (the `getDashPayProfile` precedent — parsing happens Kotlin-side, +//! keeping descriptors trivial); byte ids are lower-hex. + +use crate::support::{guard, take_pwffi_error}; +use jni::objects::{JByteArray, JClass, JString}; +use jni::sys::{jint, jlong, jstring}; +use jni::JNIEnv; +use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; +use platform_wallet_ffi::handle::Handle; +use std::ffi::CStr; +use std::os::raw::c_char; +use std::ptr; + +fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + let len = env.get_array_length(arr).ok()? as usize; + if len != 32 { + let _ = env.throw_new( + "org/dashfoundation/dashsdk/ffi/DashSDKException", + format!("{field} must be 32 bytes, got {len}"), + ); + return None; + } + let mut buf = [0i8; 32]; + env.get_byte_array_region(arr, 0, &mut buf).ok()?; + Some(buf.map(|b| b as u8)) +} + +/// Render an optional FFI C-string as an owned Rust string. +/// +/// # Safety +/// `ptr`, when non-null, must be a valid NUL-terminated C string. +unsafe fn opt_cstr(ptr: *const c_char) -> Option { + if ptr.is_null() { + None + } else { + Some(CStr::from_ptr(ptr).to_string_lossy().into_owned()) + } +} + +/// Minimal JSON string escaping for the fields emitted here (same rules +/// as `tokens::profile_to_json`). +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn hex32(bytes: &[u8; 32]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn new_jstring(env: &mut JNIEnv, s: String) -> jstring { + env.new_string(s) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) +} + +// ── Payment history ─────────────────────────────────────────────────── + +/// Read the DashPay payment history off a managed-identity handle +/// (bridges `managed_identity_get_dashpay_payments`). Returns a JSON +/// array string — one object per payment: `txid`, `counterpartyId` +/// (lower-hex 32 bytes), `amountDuffs`, `direction` (0 Sent, 1 Received), +/// `status` (0 Pending, 1 Confirmed, 2 Failed), optional `memo`. The +/// Rust-owned array is freed here via `dashpay_payment_array_free`. +/// +/// This getter is the ONLY durable source of payment rows: the Kotlin +/// refresh path (`PlatformWalletManager.refreshDashPayPayments`) upserts +/// its result into Room, mirroring iOS — the recurring DashPay sweep +/// reconciles payments in-memory without persisting them. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityDashPayPayments( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut array = platform_wallet_ffi::DashpayPaymentArray { + items: ptr::null_mut(), + count: 0, + }; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_dashpay_payments( + identity_handle as Handle, + &mut array as *mut platform_wallet_ffi::DashpayPaymentArray, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(array.count); + if !array.items.is_null() && array.count > 0 { + let items = unsafe { std::slice::from_raw_parts(array.items, array.count) }; + for p in items { + let mut fields: Vec = Vec::with_capacity(6); + if let Some(txid) = unsafe { opt_cstr(p.txid) } { + fields.push(format!("\"txid\":{}", json_string(&txid))); + } + fields.push(format!( + "\"counterpartyId\":{}", + json_string(&hex32(&p.counterparty_id)) + )); + fields.push(format!("\"amountDuffs\":{}", p.amount_duffs)); + fields.push(format!("\"direction\":{}", p.direction as u8)); + fields.push(format!("\"status\":{}", p.status as u8)); + if let Some(memo) = unsafe { opt_cstr(p.memo) } { + fields.push(format!("\"memo\":{}", json_string(&memo))); + } + rows.push(format!("{{{}}}", fields.join(","))); + } + } + unsafe { + platform_wallet_ffi::dashpay_payment_array_free( + &mut array as *mut platform_wallet_ffi::DashpayPaymentArray, + ) + }; + new_jstring(env, format!("[{}]", rows.join(","))) + }) +} + +// ── Profiles ────────────────────────────────────────────────────────── + +/// Read the cached profile of `contactIdentityId` as seen by +/// `ownerIdentityId` (bridges `platform_wallet_get_contact_profile`). +/// Returns the same JSON object shape as `TokensNative.getDashPayProfile` +/// or null when no present profile is cached. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_getContactProfile( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + owner_identity_id: JByteArray, + contact_identity_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_id32(env, &owner_identity_id, "ownerIdentityId") else { + return ptr::null_mut(); + }; + let Some(contact) = read_id32(env, &contact_identity_id, "contactIdentityId") else { + return ptr::null_mut(); + }; + let mut profile = DashPayProfileFFI::empty(); + let mut has_profile = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_contact_profile( + wallet_handle as Handle, + owner.as_ptr(), + contact.as_ptr(), + &mut profile as *mut DashPayProfileFFI, + &mut has_profile as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if !has_profile { + return ptr::null_mut(); + } + let json = crate::tokens::profile_to_json(&profile); + unsafe { + platform_wallet_ffi::dashpay_profile_ffi_free(&mut profile as *mut DashPayProfileFFI) + }; + new_jstring(env, json) + }) +} + +/// Read the managed identity's own cached DashPay profile off its handle +/// (bridges `managed_identity_get_dashpay_profile`). Same JSON shape / +/// null convention as [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_getContactProfile`]. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityDashPayProfile( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut profile = DashPayProfileFFI::empty(); + let mut has_profile = false; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_dashpay_profile( + identity_handle as Handle, + &mut profile as *mut DashPayProfileFFI, + &mut has_profile as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if !has_profile { + return ptr::null_mut(); + } + let json = crate::tokens::profile_to_json(&profile); + unsafe { + platform_wallet_ffi::dashpay_profile_ffi_free(&mut profile as *mut DashPayProfileFFI) + }; + new_jstring(env, json) + }) +} + +// ── Sync state ──────────────────────────────────────────────────────── + +/// Read the managed identity's DashPay sync state (bridges +/// `managed_identity_get_dashpay_sync_state`). Returns a JSON object of +/// the collection counts + high-water cursors; the optional cursors omit +/// their key when unset (mirroring the Swift `DashPaySyncState` optionals). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityDashPaySyncState( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut state = platform_wallet_ffi::dashpay_profile::DashPaySyncStateFFI { + established_contacts: 0, + incoming_requests: 0, + sent_requests: 0, + ignored_senders: 0, + contact_profiles: 0, + present_contact_profiles: 0, + dashpay_payments: 0, + has_dashpay_profile: false, + has_high_water_received: false, + high_water_received_ms: 0, + has_high_water_sent: false, + high_water_sent_ms: 0, + }; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_dashpay_sync_state( + identity_handle as Handle, + &mut state as *mut platform_wallet_ffi::dashpay_profile::DashPaySyncStateFFI, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut fields = vec![ + format!("\"establishedContacts\":{}", state.established_contacts), + format!("\"incomingRequests\":{}", state.incoming_requests), + format!("\"sentRequests\":{}", state.sent_requests), + format!("\"ignoredSenders\":{}", state.ignored_senders), + format!("\"contactProfiles\":{}", state.contact_profiles), + format!( + "\"presentContactProfiles\":{}", + state.present_contact_profiles + ), + format!("\"dashpayPayments\":{}", state.dashpay_payments), + format!("\"hasDashpayProfile\":{}", state.has_dashpay_profile), + ]; + if state.has_high_water_received { + fields.push(format!( + "\"highWaterReceivedMs\":{}", + state.high_water_received_ms + )); + } + if state.has_high_water_sent { + fields.push(format!("\"highWaterSentMs\":{}", state.high_water_sent_ms)); + } + new_jstring(env, format!("{{{}}}", fields.join(","))) + }) +} + +// ── Wallet-scoped DPNS search ───────────────────────────────────────── + +/// Live DPNS prefix search against Platform, wallet-scoped (bridges +/// `platform_wallet_search_dpns_names` — the call path the iOS +/// `AddContactView` drives; distinct from the SDK-handle-scoped +/// `QueriesNative.dpnsSearch`). Returns a JSON array of +/// `{"label":…,"identityId":…hex}` rows; `limit == 0` means no limit. +/// Blocking (network). The Rust-owned results are freed here via +/// `dpns_search_results_free`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_searchDpnsNames( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + prefix: JString, + limit: jint, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let prefix_str: String = match env.get_string(&prefix) { + Ok(s) => s.into(), + Err(_) => return ptr::null_mut(), + }; + let Ok(prefix_c) = std::ffi::CString::new(prefix_str) else { + return ptr::null_mut(); + }; + let mut results: *mut platform_wallet_ffi::DpnsSearchResultFFI = ptr::null_mut(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_search_dpns_names( + wallet_handle as Handle, + prefix_c.as_ptr(), + limit.max(0) as u32, + &mut results as *mut *mut platform_wallet_ffi::DpnsSearchResultFFI, + &mut count as *mut usize, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(count); + if !results.is_null() && count > 0 { + let items = unsafe { std::slice::from_raw_parts(results, count) }; + for r in items { + let label = unsafe { opt_cstr(r.label) }.unwrap_or_default(); + rows.push(format!( + "{{\"label\":{},\"identityId\":{}}}", + json_string(&label), + json_string(&hex32(&r.identity_id)) + )); + } + } + unsafe { platform_wallet_ffi::dpns_search_results_free(results, count) }; + new_jstring(env, format!("[{}]", rows.join(","))) + }) +} + +// ── Per-account wallet balances ─────────────────────────────────────── + +/// Read the per-account balance snapshot for a wallet off the manager +/// (bridges `platform_wallet_manager_get_account_balances` — drives the +/// iOS DashPay tab's account balance display). Returns a JSON array — +/// one object per account with the type tags, indices, identity ids +/// (lower-hex; all-zero when unset) and the four balance buckets. The +/// Rust-owned array is freed here. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletManagerAccountBalances( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let mut entries: *const platform_wallet_ffi::AccountBalanceEntryFFI = ptr::null(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_get_account_balances( + manager_handle as Handle, + wid.as_ptr(), + &mut entries as *mut *const platform_wallet_ffi::AccountBalanceEntryFFI, + &mut count as *mut usize, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(count); + if !entries.is_null() && count > 0 { + let items = unsafe { std::slice::from_raw_parts(entries, count) }; + for e in items { + rows.push(format!( + "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ + \"registrationIndex\":{},\"keyClass\":{},\ + \"userIdentityId\":{},\"friendIdentityId\":{},\ + \"confirmed\":{},\"unconfirmed\":{},\"immature\":{},\ + \"locked\":{},\"keysUsed\":{},\"keysTotal\":{}}}", + e.type_tag as u8, + e.standard_tag as u8, + e.index, + e.registration_index, + e.key_class, + json_string(&hex32(&e.user_identity_id)), + json_string(&hex32(&e.friend_identity_id)), + e.confirmed, + e.unconfirmed, + e.immature, + e.locked, + e.keys_used, + e.keys_total, + )); + } + } + unsafe { + platform_wallet_ffi::platform_wallet_manager_free_account_balances( + entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, + count, + ) + }; + new_jstring(env, format!("[{}]", rows.join(","))) + }) +} diff --git a/packages/rs-unified-sdk-jni/src/lib.rs b/packages/rs-unified-sdk-jni/src/lib.rs index 3c9e50962df..d614fb7132d 100644 --- a/packages/rs-unified-sdk-jni/src/lib.rs +++ b/packages/rs-unified-sdk-jni/src/lib.rs @@ -15,6 +15,7 @@ //! - Rust→Kotlin callbacks attach their (possibly Tokio) thread as a daemon. mod credits; +mod dashpay; mod events; mod funding; mod identity; diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 5b66c80d827..3f38a555ea1 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -52,11 +52,12 @@ use jni::sys::jlong; use jni::JNIEnv; use platform_wallet_ffi::{ AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI, - AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, - CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, - IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, PersistenceCallbacks, PlatformAddressFFI, - SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UtxoEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, + AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, + ContactRequestRemovalFFI, CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, + IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, PaymentRestoreEntryFFI, + PersistenceCallbacks, PlatformAddressFFI, SpentOutPointFFI, TokenBalanceRemovalFFI, + TokenBalanceUpsertFFI, TransactionRecordFFI, UtxoEntryFFI, WalletChangeSetFFI, + WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -863,35 +864,84 @@ unsafe fn persist_identity_upsert( let avatar_hash = env.byte_array_from_slice(&e.dashpay_profile_avatar_hash)?; let avatar_fp = env.byte_array_from_slice(&e.dashpay_profile_avatar_fingerprint)?; - env.call_method( - bridge, - "onPersistIdentityUpsert", - "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ - Ljava/lang/String;[BZ[BZLjava/lang/String;)I", - &[ - wid.into(), - (&identity_id).into(), - JValue::Long(e.balance as i64), - JValue::Long(e.revision as i64), - JValue::Bool(e.identity_index_is_some as u8), - JValue::Int(e.identity_index as i32), - JValue::Byte(e.status as i8), - JValue::Bool(e.wallet_id_is_some as u8), - (&identity_wallet_id).into(), - (&dpns_arr).into(), - (&acquired_arr).into(), - JValue::Bool(e.dashpay_profile_present as u8), - (&display).into(), - (&bio).into(), - (&avatar_url).into(), - (&avatar_hash).into(), - JValue::Bool(e.dashpay_profile_avatar_hash_present as u8), - (&avatar_fp).into(), - JValue::Bool(e.dashpay_profile_avatar_fingerprint_present as u8), - (&public_message).into(), - ], - )? - .i() + let code = env + .call_method( + bridge, + "onPersistIdentityUpsert", + "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ + Ljava/lang/String;[BZ[BZLjava/lang/String;)I", + &[ + wid.into(), + (&identity_id).into(), + JValue::Long(e.balance as i64), + JValue::Long(e.revision as i64), + JValue::Bool(e.identity_index_is_some as u8), + JValue::Int(e.identity_index as i32), + JValue::Byte(e.status as i8), + JValue::Bool(e.wallet_id_is_some as u8), + (&identity_wallet_id).into(), + (&dpns_arr).into(), + (&acquired_arr).into(), + JValue::Bool(e.dashpay_profile_present as u8), + (&display).into(), + (&bio).into(), + (&avatar_url).into(), + (&avatar_hash).into(), + JValue::Bool(e.dashpay_profile_avatar_hash_present as u8), + (&avatar_fp).into(), + JValue::Bool(e.dashpay_profile_avatar_fingerprint_present as u8), + (&public_message).into(), + ], + )? + .i()?; + if code != 0 { + return Ok(code); + } + + // Cached contact profiles riding the identity entry + // (`IdentityEntryFFI::contact_profiles`): one bridge call per row so + // the flat args stay manageable, mirroring the per-delta contacts + // callbacks. `is_present == true` upserts the Room row; + // `is_present == false` is a tombstone — the contact removed their + // on-chain profile and the persisted row must be DELETED (an + // upsert-only pipeline would show the stale name/avatar forever). + for row in slice_or_empty(e.contact_profiles, e.contact_profiles_count) { + let code = env.with_local_frame(16, |env| { + let contact_id = env.byte_array_from_slice(&row.contact_id)?; + let display = cstr_opt(env, row.display_name)?; + let bio = cstr_opt(env, row.bio)?; + let avatar_url = cstr_opt(env, row.avatar_url)?; + let public_message = cstr_opt(env, row.public_message)?; + let avatar_hash = env.byte_array_from_slice(&row.avatar_hash)?; + let avatar_fp = env.byte_array_from_slice(&row.avatar_fingerprint)?; + env.call_method( + bridge, + "onPersistContactProfileDelta", + "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ + [BZ[BZLjava/lang/String;J)I", + &[ + wid.into(), + (&identity_id).into(), + (&contact_id).into(), + JValue::Bool(row.is_present as u8), + (&display).into(), + (&bio).into(), + (&avatar_url).into(), + (&avatar_hash).into(), + JValue::Bool(row.avatar_hash_present as u8), + (&avatar_fp).into(), + JValue::Bool(row.avatar_fingerprint_present as u8), + (&public_message).into(), + JValue::Long(row.checked_at_ms as i64), + ], + )? + .i() + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) } // ── Identity keys ───────────────────────────────────────────────────── @@ -1478,23 +1528,45 @@ struct AccountSpecStaged { } /// Staged identity-restore row: FFI struct with `keys` / `contacts` / -/// `ignored_senders` still null / 0 until sealed, plus the owned per-key -/// and per-contact staging. `dpns_names` / `contested_dpns_names` are left -/// null / 0 this pass (not yet ported), and `payments` / -/// `contact_profiles` stay null / 0 because Kotlin has no persist source -/// for them yet (no Room analog of `PersistentDashpayPayment` / -/// `PersistentDashpayContactProfile`), so there is nothing owned for any -/// of those to free. +/// `ignored_senders` / `payments` / `contact_profiles` still null / 0 +/// until sealed, plus the owned per-row staging. `dpns_names` / +/// `contested_dpns_names` are left null / 0 this pass (not yet ported). struct IdentityRestoreStaged { - /// FFI entry with `keys` / `contacts` / `ignored_senders` (and the - /// dpns / payments / contact-profile arrays) still null / 0 until - /// sealed. + /// FFI entry with the nested arrays still null / 0 until sealed. entry: IdentityRestoreEntryFFI, keys: Vec, contacts: Vec, /// Bare 32-byte ignored-sender ids (per-sender mute) — POD, minted /// as a flat `[u8; 32]` array at seal. ignored_senders: Vec<[u8; 32]>, + payments: Vec, + contact_profiles: Vec, +} + +/// Staged DashPay payment-history row: FFI struct with `txid` / `memo` +/// still null until sealed, plus the owned strings that back them. +/// Rehydrates the managed identity's `dashpay_payments` map — without it +/// *Sent* entries (and their memos) vanish on every relaunch because the +/// reconcile sweep can only re-derive *Received* entries from UTXOs. +struct PaymentRestoreStaged { + /// FFI row with `txid` / `memo` still null until sealed. + row: PaymentRestoreEntryFFI, + /// The txid map key. Always present on a persisted row. + txid: CString, + memo: Option, +} + +/// Staged cached contact-profile row: FFI struct with the four optional +/// c-strings still null until sealed, plus the owned strings. Only +/// **present** profiles are persisted (tombstones delete the Room row), +/// so every staged row rehydrates as a present cache entry. +struct ContactProfileRestoreStaged { + /// FFI row with the string fields still null until sealed. + row: ContactProfileRestoreEntryFFI, + display_name: Option, + bio: Option, + avatar_url: Option, + public_message: Option, } /// Staged DashPay contact-restore row: FFI struct with every pointer @@ -1571,6 +1643,8 @@ fn seal_wallet_entries(staged: Vec) -> Vec = keys .into_iter() @@ -1626,6 +1700,45 @@ fn seal_wallet_entries(staged: Vec) -> Vec = payments + .into_iter() + .map( + |PaymentRestoreStaged { + mut row, + txid, + memo, + }| { + row.txid = txid.into_raw() as *const c_char; + row.memo = opt_cstring_into_raw(memo); + row + }, + ) + .collect(); + (entry.payments, entry.payments_count) = vec_into_raw(payments); + + let contact_profiles: Vec = + contact_profiles + .into_iter() + .map( + |ContactProfileRestoreStaged { + mut row, + display_name, + bio, + avatar_url, + public_message, + }| { + row.display_name = opt_cstring_into_raw(display_name); + row.bio = opt_cstring_into_raw(bio); + row.avatar_url = opt_cstring_into_raw(avatar_url); + row.public_message = + opt_cstring_into_raw(public_message); + row + }, + ) + .collect(); + (entry.contact_profiles, entry.contact_profiles_count) = + vec_into_raw(contact_profiles); entry }, ) @@ -1905,6 +2018,51 @@ fn build_identity_restore( // contactRequest documents re-ingest on the next sweep. let ignored_senders = read_id32_array_field(env, holder, "ignoredSenders")?; + // DashPay payment history — restores the `dashpay_payments` map so + // *Sent* entries (with their user-entered memos) survive relaunch; + // the reconcile sweep can only re-derive *Received* entries. Mirror + // of the Swift `buildIdentityRestoreBuffer` payments block. + let payments_obj = env + .get_field( + holder, + "payments", + "[Lorg/dashfoundation/dashsdk/ffi/PaymentRestoreData;", + )? + .l()?; + let payments_arr: jni::objects::JObjectArray = payments_obj.into(); + let payments_len = env.get_array_length(&payments_arr)? as usize; + let mut payments: Vec = Vec::with_capacity(payments_len); + for i in 0..payments_len { + let p = env.with_local_frame(16, |env| { + let h = env.get_object_array_element(&payments_arr, i as i32)?; + build_payment_restore(env, &h) + })?; + payments.push(p); + } + + // Cached contact profiles — restores the `contact_profiles` map so + // the contacts/requests UI shows names + avatars immediately after + // relaunch instead of raw identity ids until the next profile sweep. + // Only present profiles are persisted, so every row rehydrates as a + // present cache entry. + let profiles_obj = env + .get_field( + holder, + "contactProfiles", + "[Lorg/dashfoundation/dashsdk/ffi/ContactProfileRestoreData;", + )? + .l()?; + let profiles_arr: jni::objects::JObjectArray = profiles_obj.into(); + let profiles_len = env.get_array_length(&profiles_arr)? as usize; + let mut contact_profiles: Vec = Vec::with_capacity(profiles_len); + for i in 0..profiles_len { + let cp = env.with_local_frame(16, |env| { + let h = env.get_object_array_element(&profiles_arr, i as i32)?; + build_contact_profile_restore(env, &h) + })?; + contact_profiles.push(cp); + } + let entry = IdentityRestoreEntryFFI { identity_id, balance, @@ -1919,13 +2077,6 @@ fn build_identity_restore( keys_count: 0, contacts: ptr::null(), contacts_count: 0, - // Payments + cached contact profiles stay null / 0 this pass: - // Kotlin has no persist source for them yet (no Room analog of - // `PersistentDashpayPayment` / `PersistentDashpayContactProfile`), - // so there is nothing to rehydrate — the reconcile / profile - // sweeps rebuild them after load, exactly as before the arrays - // existed. The free trampoline never has to reclaim them because - // they are never minted. payments: ptr::null(), payments_count: 0, ignored_senders: ptr::null(), @@ -1938,6 +2089,92 @@ fn build_identity_restore( keys, contacts, ignored_senders, + payments, + contact_profiles, + }) +} + +/// Rebuild one DashPay payment-history row from a Kotlin +/// `PaymentRestoreData` into a [`PaymentRestoreStaged`]. The txid / memo +/// strings stay owned here; [`seal_wallet_entries`] mints the raw +/// pointers only once the whole load succeeded and +/// [`tramp_load_wallet_list_free`] reclaims them. +fn build_payment_restore( + env: &mut JNIEnv, + holder: &JObject, +) -> Result { + // txid is non-null on the Kotlin class; an interior NUL (impossible + // for a hex txid) degrades to an empty string, which the Rust + // restore fold skips as an unparseable key rather than aborting the + // whole load. + let txid = read_opt_cstring_field(env, holder, "txid")? + .unwrap_or_else(|| CString::new("").expect("empty CString")); + let counterparty_id = read_id32_field(env, holder, "counterpartyId")?; + let amount_duffs = env.get_field(holder, "amountDuffs", "J")?.j()? as u64; + let direction_raw = env.get_field(holder, "directionRaw", "B")?.b()? as u8; + let status_raw = env.get_field(holder, "statusRaw", "B")?.b()? as u8; + let memo = read_opt_cstring_field(env, holder, "memo")?; + Ok(PaymentRestoreStaged { + row: PaymentRestoreEntryFFI { + txid: ptr::null(), + counterparty_id, + amount_duffs, + direction_raw, + status_raw, + memo: ptr::null(), + }, + txid, + memo, + }) +} + +/// Rebuild one cached contact-profile row from a Kotlin +/// `ContactProfileRestoreData` into a [`ContactProfileRestoreStaged`]. +/// The four optional strings stay owned here; [`seal_wallet_entries`] +/// mints the raw pointers and [`tramp_load_wallet_list_free`] reclaims +/// them. The avatar hash / fingerprint presence flags derive from the +/// nullable Kotlin byte arrays (null ⇒ absent), mirroring the Swift +/// restore block's gating. +fn build_contact_profile_restore( + env: &mut JNIEnv, + holder: &JObject, +) -> Result { + let contact_id = read_id32_field(env, holder, "contactId")?; + let display_name = read_opt_cstring_field(env, holder, "displayName")?; + let bio = read_opt_cstring_field(env, holder, "bio")?; + let avatar_url = read_opt_cstring_field(env, holder, "avatarUrl")?; + let public_message = read_opt_cstring_field(env, holder, "publicMessage")?; + let checked_at_ms = env.get_field(holder, "checkedAtMs", "J")?.j()? as u64; + + let hash_vec = read_bytes_field_vec(env, holder, "avatarHash")?; + let (avatar_hash, avatar_hash_present) = match <[u8; 32]>::try_from(hash_vec.as_slice()) { + Ok(h) => (h, true), + Err(_) => ([0u8; 32], false), + }; + let fp_vec = read_bytes_field_vec(env, holder, "avatarFingerprint")?; + let (avatar_fingerprint, avatar_fingerprint_present) = + match <[u8; 8]>::try_from(fp_vec.as_slice()) { + Ok(f) => (f, true), + Err(_) => ([0u8; 8], false), + }; + + Ok(ContactProfileRestoreStaged { + row: ContactProfileRestoreEntryFFI { + contact_id, + display_name: ptr::null(), + bio: ptr::null(), + avatar_url: ptr::null(), + avatar_hash, + avatar_hash_present, + avatar_fingerprint, + avatar_fingerprint_present, + public_message: ptr::null(), + checked_at_ms, + }, + display_name, + bio, + avatar_url, + public_message, }) } @@ -2130,14 +2367,15 @@ unsafe extern "C" fn tramp_load_wallet_list_free( drop(specs); } - // identities + nested key / contact / ignored-sender arrays - // (each key's `data` buffer + contract-bounds doc-type C-string; - // each contact's three byte payloads, three metadata C-strings - // and `accepted_accounts` u32 buffer). Mirrors exactly what + // identities + nested key / contact / ignored-sender / + // payment / contact-profile arrays (each key's `data` buffer + + // contract-bounds doc-type C-string; each contact's three byte + // payloads, three metadata C-strings and `accepted_accounts` + // u32 buffer; each payment's txid/memo C-strings; each contact + // profile's four optional C-strings). Mirrors exactly what // `seal_wallet_entries` minted. The dpns_names / - // contested_dpns_names / payments / contact_profiles arrays are - // never minted this pass (staged null / 0), so there is nothing - // to reclaim for them. + // contested_dpns_names arrays are never minted this pass + // (staged null / 0), so there is nothing to reclaim for them. if !e.identities.is_null() && e.identities_count > 0 { let idents: Box<[IdentityRestoreEntryFFI]> = Box::from_raw(std::ptr::slice_from_raw_parts_mut( @@ -2184,6 +2422,32 @@ unsafe extern "C" fn tramp_load_wallet_list_free( // Flat POD array of 32-byte sender ids — no nested // buffers, just the boxed slice itself. free_raw_slice(ident.ignored_senders, ident.ignored_senders_count); + if !ident.payments.is_null() && ident.payments_count > 0 { + let payments: Box<[PaymentRestoreEntryFFI]> = + Box::from_raw(std::ptr::slice_from_raw_parts_mut( + ident.payments as *mut PaymentRestoreEntryFFI, + ident.payments_count, + )); + for p in payments.iter() { + free_raw_cstring(p.txid); + free_raw_cstring(p.memo); + } + drop(payments); + } + if !ident.contact_profiles.is_null() && ident.contact_profiles_count > 0 { + let profiles: Box<[ContactProfileRestoreEntryFFI]> = + Box::from_raw(std::ptr::slice_from_raw_parts_mut( + ident.contact_profiles as *mut ContactProfileRestoreEntryFFI, + ident.contact_profiles_count, + )); + for cp in profiles.iter() { + free_raw_cstring(cp.display_name); + free_raw_cstring(cp.bio); + free_raw_cstring(cp.avatar_url); + free_raw_cstring(cp.public_message); + } + drop(profiles); + } } drop(idents); } diff --git a/packages/rs-unified-sdk-jni/src/tokens.rs b/packages/rs-unified-sdk-jni/src/tokens.rs index 0781b33fe24..800409f4211 100644 --- a/packages/rs-unified-sdk-jni/src/tokens.rs +++ b/packages/rs-unified-sdk-jni/src/tokens.rs @@ -1460,11 +1460,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TokensNative_getDashP } /// Render a [`DashPayProfileFFI`] as a compact JSON object string. Optional -/// scalars omit their key when absent; byte fields are lower-hex. +/// scalars omit their key when absent; byte fields are lower-hex. Shared +/// with [`crate::dashpay`]'s profile readers so both surfaces emit the +/// same shape. /// /// # Safety /// `profile`'s string pointers, when non-null, must be valid C strings. -fn profile_to_json(profile: &DashPayProfileFFI) -> String { +pub(crate) fn profile_to_json(profile: &DashPayProfileFFI) -> String { fn opt_cstr(ptr: *const c_char) -> Option { if ptr.is_null() { None From 724c888335910e7248cd103f95fde22c12a66256 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 21:07:03 +0700 Subject: [PATCH 03/12] fix(kotlin-sdk): fold K1 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-lens review of 007af13f0c (JNI memory safety, persistence correctness, test rigor) — no must-fixes; folded the should-fixes: - refreshDashPayPayments now preserves createdAt and skips unchanged rows (a Room @Upsert rewrites every column, so the unconditional upsert clobbered createdAt with 'now' and re-fired the payments Flow on every refresh — Swift's persistDashpayPayments deliberately change-detects; now mirrored). - Empty-txid payment rows are filtered before restore staging (the Rust restore fold inserts an "" map key rather than skipping; the build_payment_restore comment claimed otherwise — comment corrected). - Rollback test for onPersistContactProfileDelta (a delta that wrote eagerly instead of staging would have passed every existing test). - Instrumented round-trip grew a tombstone-restore leg (deleted profile row restores as an absent Rust cache entry; exercises the empty-array marshaling path) and an accountBalances smoke assertion (that FFI array marshal/free path previously had no coverage at any tier); searchDpnsNames deferral to the testnet tier is now documented in the test docstring. - Clarified the owner-missing skip comment on the delta handler (the identity upsert is staged into the same buffer first, so the lookup succeeds at replay — the skip is defensive, not a retry mechanism). JVM suite: 28/28 green. Instrumented tests compile; execution remains CI-emulator-bound (no NDK locally), as before. Co-Authored-By: Claude Fable 5 --- .../wallet/WalletManagerRoundTripTest.kt | 47 ++++++++++++++++++- .../PlatformWalletPersistenceHandler.kt | 15 ++++-- .../dashsdk/wallet/PlatformWalletManager.kt | 37 ++++++++++----- .../PlatformWalletPersistenceHandlerTest.kt | 33 +++++++++++++ .../rs-unified-sdk-jni/src/persistence.rs | 9 ++-- 5 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt index 86321783bd2..34ec9d4ade4 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt @@ -12,6 +12,7 @@ import org.dashfoundation.dashsdk.security.WalletStorage import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -106,11 +107,19 @@ class WalletManagerRoundTripTest { * native marshaling (the staging/seal/free pipeline in * `rs-unified-sdk-jni/src/persistence.rs` that JVM/Robolectric tests * can never reach): inject payment / contact-profile / ignored-sender - * rows into Room exactly as the persist paths would land them, reload + * rows into Room in the shape the persist paths land them, reload * the wallet on a fresh manager (the load path marshals the rows into * Rust wallet state), then read them back out through the DashPay FFI * getters and assert field equality. Wrong lengths / nulls / * double-frees in the restore marshaling fail here, not at UAT. + * + * A third reload leg covers the tombstone consequence: a tombstoned + * (deleted) profile row must restore as an ABSENT cache entry — and + * exercises the empty-contact-profile-array marshaling path. + * + * Of the K1 getters, `searchDpnsNames` is deliberately untested here: + * it is a live network query and belongs to the `-Ptestnet=true` + * tier (KOTLIN_MIGRATION_SPEC.md §7.4). */ @Test fun dashPayRestoreRoundTripsPaymentsContactProfilesAndSyncState() = runBlocking { @@ -212,6 +221,42 @@ class WalletManagerRoundTripTest { assertEquals(1, state.getInt("contactProfiles")) assertEquals(1, state.getInt("presentContactProfiles")) assertEquals(1, state.getInt("ignoredSenders")) + + // Per-account balance snapshot: exercises the + // AccountBalanceEntryFFI array marshal/free path offline — the + // freshly-created wallet has default accounts, all zero-balance. + val balancesJson = reloaded.accountBalances(walletId) + assertNotNull("account balances readable", balancesJson) + val balances = org.json.JSONArray(balancesJson!!) + assertTrue("default accounts present", balances.length() > 0) + for (i in 0 until balances.length()) { + val account = balances.getJSONObject(i) + assertEquals(0L, account.getLong("confirmed")) + assertEquals(64, account.getString("userIdentityId").length) + } + } + + // ── Third reload: a tombstoned (deleted) profile row restores as + // an ABSENT cache entry, and the empty contact-profile array takes + // the null/0 marshaling path. + db.dashpayDao().deleteContactProfile( + Network.TESTNET.ffiValue, + identityId, + contactId, + ) + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { third -> + third.loadPersistedWallets() + val managed = third.wallet(forWalletId = walletId) + assertNotNull(managed) + assertNull( + "tombstoned profile must not resurrect", + managed!!.dashpay.getContactProfile(identityId, contactId), + ) + val state = org.json.JSONObject(managed.dashpay.syncState(identityId)!!) + assertEquals(0, state.getInt("contactProfiles")) + // The other stores are unaffected by the profile tombstone. + assertEquals(1, state.getInt("dashpayPayments")) + assertEquals(1, state.getInt("ignoredSenders")) } } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index d9506b10506..de4d5050a9e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -925,8 +925,13 @@ class PlatformWalletPersistenceHandler( checkedAtMs: Long, ): Int = guarded { stage(walletId) { db -> - // Owner identity must exist (networkRaw is read off it); skip - // silently otherwise — replayed next round. + // Owner identity must exist (networkRaw is read off it). In the + // real flow this lookup always succeeds: the identity upsert is + // staged into the same buffer BEFORE its contact-profile deltas + // (persist_identity_upsert loops the deltas after the identity + // call), so at replay the owner row is visible in the same + // transaction. The skip is defensive, matching the sibling + // contact paths. val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage if (isPresent) { db.dashpayDao().upsertContactProfile( @@ -1422,7 +1427,11 @@ class PlatformWalletPersistenceHandler( // reconciles in-memory without persisting). val paymentRows = database.dashpayDao() .getPaymentsByOwner(idRow.identityId) - .filter { it.counterpartyIdentityId.size == 32 } + // Wrong-length counterparty ids and empty txids are dropped + // up front: the Rust restore fold inserts whatever key it is + // given (an empty txid would land as an "" map key rather + // than being skipped). + .filter { it.counterpartyIdentityId.size == 32 && it.txid.isNotEmpty() } .map { p -> PaymentRestoreData( txid = p.txid, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 428bcd1fe1c..b7c67e5caca 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -556,6 +556,14 @@ class PlatformWalletManager( // still succeeds — the rows just aren't persisted this round. val networkRaw = database.identityDao().getByIdentityId(identityId)?.networkRaw ?: return@withContext json + // Skip-unchanged + preserve createdAt, mirroring Swift's + // persistDashpayPayments: a Room @Upsert rewrites every column, + // so an unconditional upsert would clobber createdAt with "now" + // on every refresh and re-fire the payments Flow (re-rendering + // an open payment list) even when nothing moved. + val existingByTxid = database.dashpayDao() + .getPaymentsByOwner(identityId) + .associateBy { it.txid } val rows = JSONArray(json) val entities = ArrayList(rows.length()) for (i in 0 until rows.length()) { @@ -563,18 +571,25 @@ class PlatformWalletManager( val txid = row.optString("txid", "") val counterparty = row.optString("counterpartyId", "").hexToBytesOrNull() if (txid.isEmpty() || counterparty == null || counterparty.size != 32) continue - entities.add( - DashpayPaymentEntity( - networkRaw = networkRaw, - ownerIdentityId = identityId, - counterpartyIdentityId = counterparty, - amountDuffs = row.optLong("amountDuffs"), - directionRaw = row.optInt("direction"), - statusRaw = row.optInt("status"), - txid = txid, - memo = if (row.has("memo")) row.getString("memo") else null, - ), + val existing = existingByTxid[txid] + val entity = DashpayPaymentEntity( + networkRaw = networkRaw, + ownerIdentityId = identityId, + counterpartyIdentityId = counterparty, + amountDuffs = row.optLong("amountDuffs"), + directionRaw = row.optInt("direction"), + statusRaw = row.optInt("status"), + txid = txid, + memo = if (row.has("memo")) row.getString("memo") else null, + createdAt = existing?.createdAt ?: java.util.Date(), ) + val unchanged = existing != null && + existing.counterpartyIdentityId.contentEquals(entity.counterpartyIdentityId) && + existing.amountDuffs == entity.amountDuffs && + existing.directionRaw == entity.directionRaw && + existing.statusRaw == entity.statusRaw && + existing.memo == entity.memo + if (!unchanged) entities.add(entity) } if (entities.isNotEmpty()) database.dashpayDao().upsertPayments(entities) json diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 3d5893a54ce..4ec4eb7eb8b 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -894,6 +894,39 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) } + @Test + fun contactProfileDeltaIsDiscardedOnChangesetRollback() = runTest { + // The delta must ride the stage() buffer like every other + // changeset write: a rolled-back round leaves no row (an eager + // write here would survive a failed Rust-side round and desync + // the mirror from the authoritative state). + val ownerId = ByteArray(32) { 28 } + val contactId = ByteArray(32) { 29 } + seedIdentity(ownerId) + + handler.onChangesetBegin(walletId) + handler.onPersistContactProfileDelta( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isPresent = true, + displayName = "Ghost", + bio = null, + avatarUrl = null, + avatarHash = ByteArray(32), + avatarHashPresent = false, + avatarFingerprint = ByteArray(8), + avatarFingerprintPresent = false, + publicMessage = null, + checkedAtMs = 1, + ) + // Still buffered. + assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + handler.onChangesetEnd(walletId, success = false) + + assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + } + @Test fun loadWalletListRoundTripsPaymentsAndContactProfiles() = runTest { // Relaunch-durability for the two #3841 stores: payments (Sent diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 3f38a555ea1..a27cf271ab0 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2103,10 +2103,11 @@ fn build_payment_restore( env: &mut JNIEnv, holder: &JObject, ) -> Result { - // txid is non-null on the Kotlin class; an interior NUL (impossible - // for a hex txid) degrades to an empty string, which the Rust - // restore fold skips as an unparseable key rather than aborting the - // whole load. + // txid is non-null on the Kotlin class and pre-filtered non-empty by + // the load path (the restore fold inserts whatever key it is given — + // an empty txid would land as an "" map key, not be skipped). An + // interior NUL (impossible for a hex txid) degrades to an empty + // string here rather than aborting the whole load. let txid = read_opt_cstring_field(env, holder, "txid")? .unwrap_or_else(|| CString::new("").expect("empty CString")); let counterparty_id = read_id32_field(env, holder, "counterpartyId")?; From 43df15b4d2b032a162cda60c4db8df309a6dabb0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 21:39:41 +0700 Subject: [PATCH 04/12] feat(kotlin-sdk): DashPay sync service, seedless unlock and writes (K2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second milestone of the DashPay Swift→Kotlin migration (docs/dashpay/KOTLIN_MIGRATION_SPEC.md §K2). Security pre-req — mnemonic out-buffer discipline (spec review must-fix): the resolver contract is now resolveMnemonicInto(byte[], byte[]): Int — Kotlin writes raw UTF-8 phrase bytes into a caller-sized buffer and zeroes every intermediate copy; the trampoline copies into Rust-owned memory and scrubs the Java buffer. No java.lang.String of the seed phrase exists anywhere on the resolver path (WalletStorage.retrieveMnemonicUtf8 + hasMnemonic existence check; KeystoreSigner capability check no longer decrypts). signWithMnemonicAndPath now scrubs its mnemonic CString on every path, including the NulError one. Bridges (13 exports in dashpay.rs/DashpayNative.kt): the 7 platform_wallet_manager_dashpay_sync_* fns, the seedless trio (verify_seed_binds_to_wallet, pending_contact_crypto_count, drain_pending_contact_crypto), profile/contactInfo writes, and dash_sdk_resolver_supports_key_type. Manager surface (PlatformWalletManager, following the codebase convention of manager-owned native loops — Swift keeps these in a manager extension too): - start/stop/isRunning/isSyncing/lastSync/setInterval/syncNow + dashPaySyncIsSyncing StateFlow via the 1 Hz change-gated poll (matching startSpvProgressPolling; poll-not-events is deliberate, it is exactly how iOS does it). - unlockWalletFromKeystore: verify → drain, auto-run per restored wallet inside loadPersistedWallets (load-bearing: the deferred contact-crypto queue is in-memory by design and self-heals only if every launch runs load → unlock → sweep). Seed-mismatch published from the verify result only (ErrorInvalidParameter scoped to that call); draining re-entrancy guard; per-wallet dashPayUnlockStatus StateFlow (draining / seedMismatch / pendingAccountBuilds). - Breadcrumb backfill deliberately NOT ported (no pre-breadcrumb Android installs exist — recorded in the spec). Dashpay surface: createOrUpdateProfile (avatar bytes hashed Rust-side) and setContactInfo returning ContactInfoPublishOutcome (the DIP-15 deferred-until-two-contacts state the UI must surface). Tests: instrumented DashPayUnlockAndSyncTest — the happy-path unlock is the end-to-end proof of the out-buffer resolver (verify derives the BIP44 xpub through the new vtable), the wrong-seed leg pins the seedMismatch contract with a foreign mnemonic fixture, and the sync-lifecycle leg pins start/stop/idempotent-start and manager-swap disposal. dashPaySyncNow and the write paths are network-bound and stay in the -Ptestnet=true tier. JVM suite 28/28 green; instrumented tests compile (CI emulator executes them). Co-Authored-By: Claude Fable 5 --- .../wallet/DashPayUnlockAndSyncTest.kt | 180 ++++++++ .../dashsdk/ffi/DashpayNative.kt | 101 +++++ .../dashsdk/ffi/MnemonicNative.kt | 27 +- .../dashsdk/security/KeystoreSigner.kt | 7 +- .../security/MnemonicResolverAndPersister.kt | 36 +- .../dashsdk/security/WalletStorage.kt | 26 ++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 86 ++++ .../dashsdk/wallet/PlatformWalletManager.kt | 274 ++++++++++++- packages/rs-unified-sdk-jni/src/dashpay.rs | 388 +++++++++++++++++- packages/rs-unified-sdk-jni/src/mnemonic.rs | 75 ++-- packages/rs-unified-sdk-jni/src/signer.rs | 33 +- 11 files changed, 1175 insertions(+), 58 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt new file mode 100644 index 00000000000..b6f66ea5ca6 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt @@ -0,0 +1,180 @@ +package org.dashfoundation.dashsdk.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.config.SdkConfig +import org.dashfoundation.dashsdk.persistence.DashDatabase +import org.dashfoundation.dashsdk.persistence.toHex +import org.dashfoundation.dashsdk.security.WalletStorage +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented coverage for the K2 seedless-unlock topology and the + * DashPay sync-service lifecycle (KOTLIN_MIGRATION_SPEC.md §K2), through + * the real native lib: + * + * - The **happy-path unlock is the end-to-end proof of the out-buffer + * mnemonic resolver**: `loadPersistedWallets` auto-runs + * `unlockWalletFromKeystore`, whose `verify_seed_binds_to_wallet` + * derives the BIP44 account-0 xpub through the + * `resolveMnemonicInto(byte[], byte[])` vtable — a broken copy/zero/ + * length in the new no-JVM-String contract fails the verify. + * - The **wrong-seed leg** pins the seed-mismatch contract: a foreign + * (valid) mnemonic stored under the wallet's id must publish + * `seedMismatch = true` on [PlatformWalletManager.dashPayUnlockStatus] + * and never sign. + * - The **sync-service leg** pins the manager-owned lifecycle: + * start → running, idempotent double-start, stop → stopped, and a + * second manager on the same DB can start it again after close + * (the manager-swap disposal path). `dashPaySyncNow` is deliberately + * NOT exercised — it is a live network sweep, `-Ptestnet=true` tier. + */ +@RunWith(AndroidJUnit4::class) +class DashPayUnlockAndSyncTest { + + // BIP39 English test vectors. + private val testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon about" + private val foreignMnemonic = + "legal winner thank year wave sausage worth useful legal winner " + + "thank yellow" + + private lateinit var db: DashDatabase + private lateinit var walletStorage: WalletStorage + private lateinit var sdk: Sdk + + @Before + fun setUp() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = DashDatabase.createInMemory(context) + walletStorage = WalletStorage(context) + sdk = Sdk.create(SdkConfig(network = Network.TESTNET)) + } + + @After + fun tearDown() { + runCatching { db.close() } + runCatching { sdk.close() } + } + + private suspend fun createWallet(manager: PlatformWalletManager): ByteArray = + manager.createWallet( + mnemonic = testMnemonic, + name = "unlock-test", + createDefaultAccounts = true, + ).walletId + + /** Await until [predicate] holds on the unlock status map, or fail. */ + private suspend fun PlatformWalletManager.awaitUnlockStatus( + key: String, + what: String, + predicate: (DashPayUnlockStatus?) -> Boolean, + ) = withTimeout(20_000) { + while (!predicate(dashPayUnlockStatus.value[key])) delay(50) + assertTrue(what, predicate(dashPayUnlockStatus.value[key])) + } + + @Test + fun loadAutoUnlocksVerifiesSeedAndDrains() = runBlocking { + val walletId: ByteArray + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = createWallet(manager) + // Storage discipline: existence check + raw-bytes read both + // work, and the bytes are the exact phrase UTF-8. + assertTrue(walletStorage.hasMnemonic(walletId)) + val utf8 = walletStorage.retrieveMnemonicUtf8(walletId) + assertNotNull(utf8) + assertEquals(testMnemonic, utf8!!.decodeToString()) + utf8.fill(0) + } + + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { reloaded -> + reloaded.loadPersistedWallets() + val key = walletId.toHex() + + // The auto-unlock published a status entry: the seed verified + // (via the out-buffer resolver) and the drain ran (no pending + // entries on a fresh wallet) and cleared its flag. + reloaded.awaitUnlockStatus(key, "unlock status published") { it != null } + assertFalse( + "seed must verify against its own wallet", + reloaded.dashPayUnlockStatus.value[key]!!.seedMismatch, + ) + reloaded.awaitUnlockStatus(key, "drain completes") { it?.draining == false } + assertEquals(0, reloaded.contactCryptoPendingCount(walletId)) + } + } + + @Test + fun wrongSeedPublishesSeedMismatchAndStaysWatchOnly() = runBlocking { + val walletId: ByteArray + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = createWallet(manager) + } + // Mis-map the Keystore slot: a DIFFERENT valid mnemonic under this + // wallet's id. The binding verify derives a different BIP44 + // account-0 xpub and must reject. + walletStorage.storeMnemonic(walletId, foreignMnemonic) + + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { reloaded -> + val restored = reloaded.loadPersistedWallets() + assertTrue("restore itself must not fail", restored.isNotEmpty()) + val key = walletId.toHex() + reloaded.awaitUnlockStatus(key, "seed mismatch published") { + it?.seedMismatch == true + } + assertFalse( + "no drain may run on a mismatched seed", + reloaded.dashPayUnlockStatus.value[key]!!.draining, + ) + } + } + + @Test + fun dashPaySyncLifecycleSurvivesManagerSwap() = runBlocking { + val walletId: ByteArray + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = createWallet(manager) + + assertFalse(manager.isDashPaySyncRunning()) + manager.startDashPaySync() + assertTrue(manager.isDashPaySyncRunning()) + // Idempotent double-start. + manager.startDashPaySync() + assertTrue(manager.isDashPaySyncRunning()) + + manager.setDashPaySyncInterval(300) + + manager.stopDashPaySync() + assertFalse(manager.isDashPaySyncRunning()) + + // Leave it running into close() — the manager-swap disposal + // path must not leak the loop into the next manager. + manager.startDashPaySync() + assertTrue(manager.isDashPaySyncRunning()) + } + + // Fresh manager on the same DB: its own sweep starts cleanly. + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { second -> + second.loadPersistedWallets() + assertNotNull(second.wallet(forWalletId = walletId)) + assertFalse(second.isDashPaySyncRunning()) + second.startDashPaySync() + assertTrue(second.isDashPaySyncRunning()) + second.stopDashPaySync() + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 2f127d39744..b4f0fe06939 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -72,4 +72,105 @@ internal object DashpayNative { * `keysTotal`. ← Swift `PlatformWalletManager.accountBalances(for:)`. */ external fun walletManagerAccountBalances(managerHandle: Long, walletId: ByteArray): String? + + // ── Recurring DashPay sync service (manager-scoped) ─────────────── + + /** Start the recurring DashPay sweep (idempotent Rust-side). */ + external fun dashPaySyncStart(managerHandle: Long) + + /** Stop the recurring sweep; leaves it restartable. */ + external fun dashPaySyncStop(managerHandle: Long) + + /** Whether the sweep loop is running. */ + external fun dashPaySyncIsRunning(managerHandle: Long): Boolean + + /** Whether a sweep pass is executing right now (the 1 Hz poll target). */ + external fun dashPaySyncIsSyncing(managerHandle: Long): Boolean + + /** Unix seconds of the last completed sweep; 0 when never. */ + external fun dashPaySyncLastSyncUnixSeconds(managerHandle: Long): Long + + /** Set the sweep interval in seconds (applies from the next tick). */ + external fun dashPaySyncSetInterval(managerHandle: Long, intervalSeconds: Long) + + /** + * Run one sweep pass NOW, blocking until it completes. Returns + * `{"success":…,"errors":…,"syncUnixSeconds":…}` (Swift + * `DashPaySyncSummary`). + */ + external fun dashPaySyncNow(managerHandle: Long): String? + + // ── Seedless unlock ─────────────────────────────────────────────── + + /** + * Verify the Keystore-resolved mnemonic reproduces this wallet's key + * material. A stored-but-foreign seed throws with + * `ErrorInvalidParameter` — callers disambiguate the seed-mismatch + * case ONLY by scoping their catch to this call (the Swift contract). + */ + external fun verifySeedBindsToWallet(walletHandle: Long, coreSignerHandle: Long) + + /** Deferred contact-crypto entries queued on the wallet (in-memory). */ + external fun pendingContactCryptoCount(walletHandle: Long): Int + + /** + * Drain the deferred contact-crypto queue; returns the drained-entry + * count. Blocking and potentially slow (network + ECDH per entry) — + * IO thread only; keep the signer/resolver bridges strongly + * reachable for the whole call. [signerHandle] may be 0 for + * resolver-only drains. + */ + external fun drainPendingContactCrypto( + walletHandle: Long, + signerHandle: Long, + coreSignerHandle: Long, + ): Int + + // ── Profile / contactInfo writes ────────────────────────────────── + + /** + * Create ([doCreate]) or update the DashPay profile, signing with + * [signerHandle]. [avatarBytes] is the raw image — Rust computes the + * SHA-256 hash + perceptual fingerprint. Broadcasts a real document + * state transition (blocking, network). Returns the resulting + * profile JSON. ← Swift `createDashPayProfile` / `updateDashPayProfile`. + */ + @Suppress("LongParameterList") + external fun createOrUpdateProfile( + walletHandle: Long, + identityId: ByteArray, + displayName: String?, + publicMessage: String?, + avatarUrl: String?, + avatarBytes: ByteArray?, + doCreate: Boolean, + signerHandle: Long, + ): String? + + /** + * Set owner-private contactInfo (alias / note / displayHidden) for + * `(identityId, contactId)`. Local state always updates; the + * encrypted on-chain publish is DIP-15-gated. Returns the outcome + * discriminant: 0 published, 1 deferred until two contacts, + * 2 skipped (watch-only). ← Swift `setDashPayContactInfo`. + */ + @Suppress("LongParameterList") + external fun setContactInfo( + walletHandle: Long, + identityId: ByteArray, + contactId: ByteArray, + alias: String?, + note: String?, + displayHidden: Boolean, + signerHandle: Long, + coreSignerHandle: Long, + ): Int + + /** + * Whether the mnemonic resolver can derive-sign identity keys of + * [keyType] — the preflight predicate keeping `canSignWith` + * consistent with the sign path's UNSUPPORTED_KEY_TYPE rejection. + * ← Swift `KeychainSigner.resolverCanDeriveSign`. + */ + external fun resolverSupportsKeyType(keyType: Int): Boolean } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt index ec04a4ff0a2..4bb65e55f4a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt @@ -28,14 +28,31 @@ internal object MnemonicNative { * Rust (possibly on Tokio worker threads) — implementations must be * thread-safe and fast, and MUST NOT throw. * - * Method name and `([B)Ljava/lang/String;` signature are looked up by the - * native trampoline — keep in sync with `mnemonic.rs`. + * Method name and `([B[B)I` signature are looked up by the native + * trampoline — keep in sync with `mnemonic.rs`. */ abstract class NativeMnemonicBridge { + companion object { + /** No mnemonic stored for the wallet (watch-only / external-signable). */ + const val RESOLVE_NOT_FOUND: Int = -1 + + /** The phrase does not fit in the caller-supplied buffer. */ + const val RESOLVE_BUFFER_TOO_SMALL: Int = -2 + } + /** - * Return the BIP-39 phrase for the 32-byte [walletId], or null when no - * mnemonic is stored (watch-only / external-signable wallets). + * Write the UTF-8 bytes of the BIP-39 phrase for the 32-byte + * [walletId] into the caller-supplied [out] buffer and return the + * byte count, or [RESOLVE_NOT_FOUND] / [RESOLVE_BUFFER_TOO_SMALL]. + * + * Out-buffer discipline (mirrors iOS `MaskedMnemonicUTF8` / + * `retrieveMnemonicUTF8Bytes`): the phrase must never be materialized + * as a JVM `String` — an immutable String cannot be scrubbed and + * lives on the heap until (if ever) collected, recoverable from a + * heap dump. Implementations copy raw bytes into [out] and zero every + * intermediate buffer of their own before returning; the native + * trampoline zeroes [out] after copying it into Rust-owned memory. */ - abstract fun resolveMnemonic(walletId: ByteArray): String? + abstract fun resolveMnemonicInto(walletId: ByteArray, out: ByteArray): Int } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index f9a6a9e4e94..80e9c3b109c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt @@ -173,13 +173,14 @@ class KeystoreSigner( override fun canSignWith(pubkeyBytes: ByteArray, keyType: Int): Boolean = try { if (keyType == PLATFORM_ADDRESS_HASH_KEY_TYPE) { // Prerequisites for the derive-and-sign path: a row with a - // non-empty derivation path plus a stored wallet mnemonic. Do - // NOT materialize the mnemonic here — existence only. + // non-empty derivation path plus a stored wallet mnemonic. + // Existence-only — hasMnemonic never decrypts, so no plaintext + // is materialized for a mere capability check. runBlocking { val row = platformAddressDao.getByAddressHash(pubkeyBytes) row != null && row.derivationPath.isNotEmpty() && - storage.retrieveMnemonic(row.walletId) != null + storage.hasMnemonic(row.walletId) } } else { runBlocking { storage.hasPrivateKey(storageKeyFor(pubkeyBytes)) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt index 2d02f64fd78..e4c0e2736e1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt @@ -8,9 +8,12 @@ import org.dashfoundation.dashsdk.ffi.NativeMnemonicBridge * Decrypt-on-demand mnemonic resolver — port of * `MnemonicResolverAndPersister.swift`. * - * Rust calls [resolveMnemonic] synchronously whenever a derivation needs - * the seed; per the CLAUDE.md doctrine the phrase crosses the boundary - * exactly there and nowhere else. Persisting new mnemonics goes through + * Rust calls [resolveMnemonicInto] synchronously whenever a derivation + * needs the seed; per the CLAUDE.md doctrine the phrase crosses the + * boundary exactly there and nowhere else, as raw UTF-8 bytes written + * into the caller's buffer — never a JVM `String` (the iOS out-buffer + * discipline; an immutable String can't be scrubbed and would sit in the + * heap recoverable from a dump). Persisting new mnemonics goes through * [WalletStorage.storeMnemonic] (Keystore-wrapped AES-GCM in DataStore). * * [nativeHandle] is the `MnemonicResolverHandle` to pass into FFI entry @@ -31,14 +34,29 @@ class MnemonicResolverAndPersister( /** * Synchronous resolve on the calling (Tokio) thread. `runBlocking` is * required — the FFI contract is synchronous write-into-buffer — and - * safe: this never runs on the main thread. + * safe: this never runs on the main thread. Every intermediate copy + * of the phrase is zeroed before returning; [out] itself is zeroed by + * the native trampoline after the copy into Rust-owned memory. */ - override fun resolveMnemonic(walletId: ByteArray): String? = try { - runBlocking { storage.retrieveMnemonic(walletId) } + override fun resolveMnemonicInto(walletId: ByteArray, out: ByteArray): Int = try { + val plain = runBlocking { storage.retrieveMnemonicUtf8(walletId) } + when { + plain == null -> RESOLVE_NOT_FOUND + plain.size > out.size -> { + plain.fill(0) + RESOLVE_BUFFER_TOO_SMALL + } + else -> { + plain.copyInto(out) + val len = plain.size + plain.fill(0) + len + } + } } catch (_: Exception) { - // Contract: never throw across JNI; null → NOT_FOUND on the Rust - // side, which surfaces as a wallet-operation error. - null + // Contract: never throw across JNI; NOT_FOUND surfaces as a + // wallet-operation error on the Rust side. + RESOLVE_NOT_FOUND } override fun close() { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index 57a39600162..567999c826f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -37,6 +37,12 @@ class WalletStorage( store.edit { it[mnemonicKey(walletId)] = encode(blob) } } + /** + * Decrypt the mnemonic as a display `String`. For explicit + * user-facing reveal flows ONLY (seed backup, biometric reveal) — + * a String cannot be scrubbed afterwards. Programmatic consumers + * (the FFI resolver, signers) must use [retrieveMnemonicUtf8]. + */ suspend fun retrieveMnemonic(walletId: ByteArray): String? { val encoded = store.data.first()[mnemonicKey(walletId)] ?: return null val plain = keystore.decrypt(decode(encoded)) @@ -45,6 +51,26 @@ class WalletStorage( return phrase } + /** + * Decrypt the mnemonic as raw UTF-8 bytes, never materializing a JVM + * `String` (the iOS `retrieveMnemonicUTF8Bytes` discipline). The + * caller OWNS the returned array and MUST `fill(0)` it as soon as the + * bytes are consumed — unlike a String, a ByteArray can actually be + * scrubbed, so the plaintext exposure window is bounded by the call + * instead of by the garbage collector. + */ + suspend fun retrieveMnemonicUtf8(walletId: ByteArray): ByteArray? { + val encoded = store.data.first()[mnemonicKey(walletId)] ?: return null + return keystore.decrypt(decode(encoded)) + } + + /** + * Whether a mnemonic is stored for [walletId]. Existence-only — never + * decrypts, never materializes plaintext (Swift `hasMnemonic(for:)`). + */ + suspend fun hasMnemonic(walletId: ByteArray): Boolean = + store.data.first().contains(mnemonicKey(walletId)) + suspend fun deleteMnemonic(walletId: ByteArray) { store.edit { it.remove(mnemonicKey(walletId)) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index ae657ace94a..3961eb73f65 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -319,6 +319,62 @@ class Dashpay internal constructor(private val walletHandle: Long) { mapNativeErrors { DashpayNative.searchDpnsNames(walletHandle, prefix, limit) } } + // ── Profile / contactInfo writes (upstream #3841 parity) ────────── + + /** + * Create ([doCreate] = true) or update the DashPay profile for + * [identityId], signing with [signerHandle]. [avatarBytes] is the raw + * image — Rust computes the SHA-256 hash + perceptual fingerprint. + * Broadcasts a real document state transition (blocking, network). + * Returns the resulting profile JSON (same shape as [getProfile]). + * ← Swift `createDashPayProfile` / `updateDashPayProfile`. + */ + @Suppress("LongParameterList") + suspend fun createOrUpdateProfile( + identityId: ByteArray, + displayName: String?, + publicMessage: String?, + avatarUrl: String?, + avatarBytes: ByteArray? = null, + doCreate: Boolean, + signerHandle: Long, + ): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.createOrUpdateProfile( + walletHandle, identityId, displayName, publicMessage, + avatarUrl, avatarBytes, doCreate, signerHandle, + ) + } + } + + /** + * Set the owner-private contactInfo (alias / note / [displayHidden]) + * for `(identityId, contactId)`. Local state ALWAYS updates; the + * encrypted on-chain publish is DIP-15-gated — the returned + * [ContactInfoPublishOutcome] tells the UI whether the change is + * cross-device yet ([ContactInfoPublishOutcome.DEFERRED_UNTIL_TWO_CONTACTS] + * means local-only until a second contact establishes; surface that, + * matching iOS). ← Swift `setDashPayContactInfo`. + */ + @Suppress("LongParameterList") + suspend fun setContactInfo( + identityId: ByteArray, + contactId: ByteArray, + alias: String?, + note: String?, + displayHidden: Boolean, + signerHandle: Long, + coreSignerHandle: Long, + ): ContactInfoPublishOutcome = withContext(Dispatchers.IO) { + val raw = mapNativeErrors { + DashpayNative.setContactInfo( + walletHandle, identityId, contactId, alias, note, + displayHidden, signerHandle, coreSignerHandle, + ) + } + ContactInfoPublishOutcome.fromRaw(raw) + } + /** * Open the managed-identity handle for [identityId], run [block], * and destroy the handle before returning (the [contacts] / @@ -353,6 +409,36 @@ class Dashpay internal constructor(private val walletHandle: Long) { } } +/** + * Outcome of a contactInfo write — mirror of the Rust + * `CONTACT_INFO_*` discriminants (Swift `ContactInfoPublishOutcome`). + * Local state always updated; this describes the on-chain publish. + */ +enum class ContactInfoPublishOutcome(val raw: Int) { + /** Encrypted contactInfo document broadcast — cross-device. */ + PUBLISHED(0), + + /** + * DIP-15 gate: fewer than two established contacts, so the encrypted + * publish is deferred (local-only until a second contact establishes). + */ + DEFERRED_UNTIL_TWO_CONTACTS(1), + + /** Watch-only wallet — cannot sign the publish; local-only. */ + SKIPPED_WATCH_ONLY(2), + ; + + companion object { + /** + * Unknown discriminants degrade to [DEFERRED_UNTIL_TWO_CONTACTS] + * (the "local-only, not yet cross-device" reading — the safe + * assumption for a newer Rust enum case). + */ + fun fromRaw(raw: Int): ContactInfoPublishOutcome = + entries.firstOrNull { it.raw == raw } ?: DEFERRED_UNTIL_TWO_CONTACTS + } +} + /** Owned native `ContactRequest` handle from [Dashpay.sendContactRequest]. */ class ContactRequestRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index b7c67e5caca..e5b691edb35 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.FundingNative @@ -457,7 +458,6 @@ class PlatformWalletManager( * per restorable id to obtain a [ManagedPlatformWallet] handle. * * Idempotent: with no persisted state, leaves [wallets] untouched. - * Signing fails on watch-only wallets until a future unlock flow. */ suspend fun loadPersistedWallets(): List = withContext(Dispatchers.IO) { mapNativeErrors { WalletManagerNative.loadFromPersistor(managerHandle) } @@ -482,6 +482,23 @@ class PlatformWalletManager( val managed = ManagedPlatformWallet(handle = handle, walletId = walletId) restored.add(managed) additions[walletId.toHex()] = managed + + // Seedless unlock of the just-restored external-signable wallet: + // verify the Keystore-resolved seed binds to this wallet and + // drain any deferred contact-crypto. Best-effort, per wallet — + // this call is LOAD-BEARING for DashPay recovery: the deferred + // contact-crypto queue is in-memory only (rebuilt by the sweep, + // cleared by the drain), so recovery is self-healing ONLY when + // every launch runs load → unlock → sweep. A banner-triggered + // unlock alone would leave contacts half-established after each + // process restart. Mirrors Swift `loadFromPersistor`. + try { + unlockWalletFromKeystore(managed) + } catch (_: Exception) { + // Logged inside (seedMismatch published on the status flow); + // one wallet's unlock failure can't fail the whole restore — + // the wallet simply stays external-signable (cannot sign). + } } if (additions.isNotEmpty()) { _wallets.update { it + additions } @@ -1143,6 +1160,226 @@ class PlatformWalletManager( } } + // ── DashPay sync + seedless unlock ──────────────────────────────── + // + // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow + // in `PlatformWalletManager.swift` (563-668). The recurring sweep is + // Rust-owned and manager-scoped; like the SPV/identity/shielded loops + // above, its surface lives on the manager (Swift keeps it in a manager + // extension). Status is REFLECTED via the same 1 Hz change-gated poll + // pattern as [spvProgress] — polling, not events, is deliberate: it is + // exactly how iOS does it, and naive re-assignment burned CPU there. + + private val _dashPaySyncIsSyncing = MutableStateFlow(false) + + /** Whether a DashPay sweep pass is executing right now (1 Hz poll). */ + val dashPaySyncIsSyncing: StateFlow = _dashPaySyncIsSyncing.asStateFlow() + + private val _dashPayUnlockStatus = + MutableStateFlow>(emptyMap()) + + /** + * Per-wallet seedless-unlock status, keyed by wallet-id hex — Swift + * `dashPayUnlockStatus`. `draining` while a deferred-contact-crypto + * drain is in flight; `seedMismatch` when the stored seed failed the + * binding verify (security-relevant: a mis-mapped Keystore slot); + * `pendingAccountBuilds` from the 1 Hz poll (drives the unlock banner). + */ + val dashPayUnlockStatus: StateFlow> = + _dashPayUnlockStatus.asStateFlow() + + private var dashPayPollJob: Job? = null + + /** Start the recurring DashPay sweep + the 1 Hz status poll. */ + suspend fun startDashPaySync() = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncStart(managerHandle) } + startDashPayStatusPolling() + } + + /** Stop the recurring sweep (restartable) and its status poll. */ + suspend fun stopDashPaySync() = withContext(Dispatchers.IO) { + dashPayPollJob?.cancel() + dashPayPollJob = null + mapNativeErrors { DashpayNative.dashPaySyncStop(managerHandle) } + _dashPaySyncIsSyncing.value = false + } + + suspend fun isDashPaySyncRunning(): Boolean = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncIsRunning(managerHandle) } + } + + suspend fun isDashPaySyncing(): Boolean = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncIsSyncing(managerHandle) } + } + + /** Unix seconds of the last completed sweep; 0 when never. */ + suspend fun dashPayLastSyncUnixSeconds(): Long = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncLastSyncUnixSeconds(managerHandle) } + } + + /** Set the sweep interval (seconds); applies from the next tick. */ + suspend fun setDashPaySyncInterval(seconds: Long) = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncSetInterval(managerHandle, seconds) } + } + + /** + * Run one sweep pass NOW (pull-to-refresh), blocking until it + * completes. ← Swift `dashPaySyncNow()`. + */ + suspend fun dashPaySyncNow(): DashPaySyncSummary = withContext(Dispatchers.IO) { + val json = mapNativeErrors { DashpayNative.dashPaySyncNow(managerHandle) } + ?: return@withContext DashPaySyncSummary(0, 0, 0) + val obj = org.json.JSONObject(json) + DashPaySyncSummary( + success = obj.optInt("success"), + errors = obj.optInt("errors"), + syncUnixSeconds = obj.optLong("syncUnixSeconds"), + ) + } + + /** Deferred contact-crypto entries queued on [walletId]'s wallet. */ + suspend fun contactCryptoPendingCount(walletId: ByteArray): Int = + withContext(Dispatchers.IO) { + val managed = wallet(forWalletId = walletId) ?: return@withContext 0 + mapNativeErrors { DashpayNative.pendingContactCryptoCount(managed.handle) } + } + + /** + * Seedless unlock of a restored external-signable wallet — port of + * Swift `unlockWalletFromKeychain` (verify → drain; the identity-key + * breadcrumb backfill step is deliberately NOT ported: the Kotlin SDK + * has no pre-breadcrumb installs to heal). + * + * 1. **Verify** the Keystore-resolved seed binds to this wallet + * (derives the BIP44 account-0 xpub through the resolver and + * compares with the persisted one) — a mis-mapped Keystore slot + * fails here and the wallet stays external-signable, so a wrong + * seed can never sign. + * 2. **Drain** deferred contact-crypto in the background (network + + * ECDH per entry). Guarded against stacking on an in-flight drain. + * + * The seed never crosses into Kotlin: the mnemonic → seed conversion + * happens inside the resolver vtable, and the existence check is + * [WalletStorage.hasMnemonic] (no decrypt). + * + * @return true when the seed verified (drain scheduled); false when + * no mnemonic is stored (a genuine watch-only wallet). + * @throws DashSdkError when the verify fails — a seed mismatch is + * published on [dashPayUnlockStatus] before rethrowing. + */ + suspend fun unlockWalletFromKeystore(managed: ManagedPlatformWallet): Boolean { + val walletId = managed.walletId + require(walletId.size == 32) { "walletId must be 32 bytes, got ${walletId.size}" } + if (!walletStorage.hasMnemonic(walletId)) return false + + val key = walletId.toHex() + // Wrong-seed / wrong-wallet gate. `seedMismatch` is published from + // the verify result itself, scoped to JUST this call: the verify + // FFI maps Rust `SeedMismatch` → ErrorInvalidParameter (de-offset + // native code 2), and scoping the check here keeps any other + // invalid-parameter failure elsewhere from being mistaken for a + // seed mismatch. Rethrown so callers keep their own handling. + try { + withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.verifySeedBindsToWallet( + managed.handle, + mnemonicResolver.nativeHandle, + ) + } + } + updateUnlockStatus(key) { it.copy(seedMismatch = false) } + } catch (e: DashSdkError.PlatformWallet.Generic) { + if (e.nativeCode == PWFFI_INVALID_PARAMETER) { + updateUnlockStatus(key) { it.copy(seedMismatch = true) } + } + throw e + } + + // Don't stack a second drain on an in-flight one: a banner Unlock + // tap while a drain runs would duplicate the network re-fetch + + // ECDH work and race the channel-broken writes. + if (_dashPayUnlockStatus.value[key]?.draining == true) return true + updateUnlockStatus(key) { it.copy(draining = true) } + + // Drain in the background — it re-fetches and decrypts over the + // network, so it must not block the caller. [signer] and + // [mnemonicResolver] are manager members (strongly reachable for + // the manager's lifetime), so their bridge GlobalRefs outlive the + // call. The raw wallet handle is captured, not the wrapper: a + // wallet destroyed before the drain runs just misses Rust-side + // (NotFound) — no use-after-free. An auth-gated signing failure + // inside the drain (Android-only: identity keys are biometric- + // gated here, unlike iOS) leaves the entry queued — the sweep + // self-heals — and surfaces like any other drain error. + val walletHandle = managed.handle + scope.launch(Dispatchers.IO) { + try { + mapNativeErrors { + DashpayNative.drainPendingContactCrypto( + walletHandle, + signer.nativeHandle, + mnemonicResolver.nativeHandle, + ) + } + } catch (_: Exception) { + // Not fatal: the next signer-present DashPay action (or the + // next unlock) re-attempts; the queue rebuilds via the sweep. + } finally { + updateUnlockStatus(key) { it.copy(draining = false) } + } + } + return true + } + + private inline fun updateUnlockStatus( + key: String, + transform: (DashPayUnlockStatus) -> DashPayUnlockStatus, + ) { + _dashPayUnlockStatus.update { map -> + val next = transform(map[key] ?: DashPayUnlockStatus()) + if (map[key] == next) map else map + (key to next) + } + } + + /** + * Launch the 1 Hz DashPay status poll (idempotent). Change-gated like + * [startSpvProgressPolling]: `isSyncing` plus per-wallet + * `pendingAccountBuilds`, with stale wallet keys pruned — mirror of + * the Swift `startProgressPolling` DashPay block. + */ + private fun startDashPayStatusPolling() { + if (dashPayPollJob?.isActive == true) return + dashPayPollJob = scope.launch { + while (isActive && !isClosed) { + val syncing = runCatching { + DashpayNative.dashPaySyncIsSyncing(managerHandle) + }.getOrDefault(false) + if (syncing != _dashPaySyncIsSyncing.value) { + _dashPaySyncIsSyncing.value = syncing + } + + val current = _wallets.value + _dashPayUnlockStatus.update { map -> + var next = map + // Prune wallets that no longer exist on this manager. + for (staleKey in map.keys - current.keys) next = next - staleKey + for ((hexId, managed) in current) { + val pending = runCatching { + DashpayNative.pendingContactCryptoCount(managed.handle) + }.getOrDefault(0) + val prev = next[hexId] ?: DashPayUnlockStatus() + if (prev.pendingAccountBuilds != pending) { + next = next + (hexId to prev.copy(pendingAccountBuilds = pending)) + } + } + next + } + delay(POLL_INTERVAL_MS) + } + } + } + // ── Lifecycle ───────────────────────────────────────────────────── val isClosed: Boolean get() = bundleRef.get() == 0L @@ -1166,12 +1403,14 @@ class PlatformWalletManager( val bundle = bundleRef.getAndSet(0) if (bundle == 0L) return - // Stop the progress poll loop + event fan-out scope before teardown + // Stop the progress poll loops + event fan-out scope before teardown // so no collector touches a destroyed handle. progressPollJob?.cancel() + dashPayPollJob?.cancel() scope.cancel() // Best-effort stop; ignore failures (destroy shuts everything down). + runCatching { DashpayNative.dashPaySyncStop(managerHandle) } runCatching { WalletManagerNative.platformAddressSyncStop(managerHandle) } runCatching { WalletManagerNative.identitySyncStop(managerHandle) } runCatching { WalletManagerNative.shieldedSyncStop(managerHandle) } @@ -1193,5 +1432,36 @@ class PlatformWalletManager( private companion object { /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ const val POLL_INTERVAL_MS = 1_000L + + /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ + const val PWFFI_INVALID_PARAMETER = 2 } } + +/** + * Per-wallet seedless-unlock status — Swift `DashPayUnlockStatus`. + * Published on [PlatformWalletManager.dashPayUnlockStatus]; drives the + * DashPay tab's unlock banner. + */ +data class DashPayUnlockStatus( + /** A deferred-contact-crypto drain is in flight for this wallet. */ + val draining: Boolean = false, + /** + * The stored seed failed the binding verify — a mis-mapped Keystore + * slot. Security-relevant: the wallet stays external-signable, so the + * wrong seed can never sign for it. + */ + val seedMismatch: Boolean = false, + /** Deferred contact-crypto entries queued (from the 1 Hz poll). */ + val pendingAccountBuilds: Int = 0, +) + +/** + * One `dashPaySyncNow` result — Swift `DashPaySyncSummary`: + * per-identity success/error counts + the sweep's unix-seconds stamp. + */ +data class DashPaySyncSummary( + val success: Int, + val errors: Int, + val syncUnixSeconds: Long, +) diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index d8f17bcefc6..cbab4b6c0f9 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -20,10 +20,11 @@ use crate::support::{guard, take_pwffi_error}; use jni::objects::{JByteArray, JClass, JString}; -use jni::sys::{jint, jlong, jstring}; +use jni::sys::{jboolean, jint, jlong, jstring}; use jni::JNIEnv; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; use platform_wallet_ffi::handle::Handle; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; use std::ptr; @@ -411,3 +412,388 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletM new_jstring(env, format!("[{}]", rows.join(","))) }) } + +// ── Recurring DashPay sync service (manager-scoped) ─────────────────── +// +// Bridges the `platform_wallet_manager_dashpay_sync_*` family — the +// recurring background sweep (contact requests + profiles + reconcile) +// that `DashpaySyncService` owns on the Kotlin side, mirroring +// `PlatformWalletManagerDashPaySync.swift`. + +/// Start the recurring DashPay sweep. Idempotent Rust-side. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncStart( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_start( + manager_handle as Handle, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Stop the recurring DashPay sweep. Leaves it restartable. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncStop( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_stop(manager_handle as Handle) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Whether the recurring sweep loop is running. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncIsRunning( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jboolean { + guard(&mut env, 0, |env| { + let mut running = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_is_running( + manager_handle as Handle, + &mut running as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + running as jboolean + }) +} + +/// Whether a sweep pass is executing right now (the 1 Hz poll target). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncIsSyncing( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jboolean { + guard(&mut env, 0, |env| { + let mut syncing = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_is_syncing( + manager_handle as Handle, + &mut syncing as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + syncing as jboolean + }) +} + +/// Unix seconds of the last completed sweep; 0 when never. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncLastSyncUnixSeconds( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + let mut last: u64 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_last_sync_unix_seconds( + manager_handle as Handle, + &mut last as *mut u64, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + last as jlong + }) +} + +/// Set the sweep interval in seconds (takes effect from the next tick). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncSetInterval( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + interval_seconds: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_set_interval( + manager_handle as Handle, + interval_seconds.max(0) as u64, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Run one sweep pass NOW (pull-to-refresh), blocking until it +/// completes. Returns a JSON object +/// `{"success":…,"errors":…,"syncUnixSeconds":…}` mirroring the Swift +/// `DashPaySyncSummary`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncNow( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut success: usize = 0; + let mut errors: usize = 0; + let mut sync_unix: u64 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_sync_now( + manager_handle as Handle, + &mut success as *mut usize, + &mut errors as *mut usize, + &mut sync_unix as *mut u64, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + new_jstring( + env, + format!( + "{{\"success\":{},\"errors\":{},\"syncUnixSeconds\":{}}}", + success, errors, sync_unix + ), + ) + }) +} + +// ── Seedless unlock (verify seed binding + deferred-crypto drain) ───── + +/// Verify that the Keystore-resolved mnemonic reproduces this wallet's +/// key material (bridges `platform_wallet_verify_seed_binds_to_wallet`). +/// A stored-but-foreign seed surfaces as `ErrorInvalidParameter` — the +/// Kotlin caller disambiguates the seed-mismatch case ONLY by scoping +/// its catch to this call (the Swift contract). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_verifySeedBindsToWallet( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + core_signer_handle: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_verify_seed_binds_to_wallet( + wallet_handle as Handle, + core_signer_handle as *mut MnemonicResolverHandle, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Number of deferred contact-crypto entries queued on the wallet +/// (in-memory queue — rebuilt by the sweep, cleared by the drain). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_pendingContactCryptoCount( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, +) -> jint { + guard(&mut env, 0, |env| { + let mut count: u32 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_pending_contact_crypto_count( + wallet_handle as Handle, + &mut count as *mut u32, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + count as jint + }) +} + +/// Drain the deferred contact-crypto queue: per entry, run the seed-side +/// op (receiving-xpub register / external-account build / contactInfo +/// decrypt / auto-accept) through [`core_signer_handle`], signing any +/// reciprocal transitions with [`signer_handle`] (nullable — pass 0 for +/// resolver-only drains). Returns the drained-entry count. Blocking and +/// potentially slow (network + ECDH per entry) — never call on the main +/// thread; the caller keeps both bridge objects strongly reachable for +/// the whole call. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_drainPendingContactCrypto( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jint { + guard(&mut env, 0, |env| { + let mut drained: u32 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_drain_pending_contact_crypto( + wallet_handle as Handle, + signer_handle as *mut SignerHandle, + core_signer_handle as *mut MnemonicResolverHandle, + &mut drained as *mut u32, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + drained as jint + }) +} + +// ── Profile / contactInfo writes ────────────────────────────────────── + +/// Create (`doCreate == true`) or update the DashPay profile for +/// `identityId`, signing with `signer_handle`. `avatarBytes` is the raw +/// image — Rust computes the SHA-256 hash + perceptual fingerprint. +/// Broadcasts a real document state transition (blocking, network). +/// Returns the resulting profile as JSON (same shape as the readers). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createOrUpdateProfile( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + display_name: JString, + public_message: JString, + avatar_url: JString, + avatar_bytes: JByteArray, + do_create: jboolean, + signer_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let display = opt_jstring_to_cstring(env, &display_name); + let message = opt_jstring_to_cstring(env, &public_message); + let url = opt_jstring_to_cstring(env, &avatar_url); + let avatar: Option> = if avatar_bytes.is_null() { + None + } else { + env.convert_byte_array(&avatar_bytes).ok() + }; + + let mut profile = DashPayProfileFFI::empty(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_create_or_update_dashpay_profile_with_signer( + wallet_handle as Handle, + id.as_ptr(), + display.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + message.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + url.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + avatar.as_ref().map_or(ptr::null(), |v| v.as_ptr()), + avatar.as_ref().map_or(0, |v| v.len()), + do_create != 0, + signer_handle as *mut SignerHandle, + &mut profile as *mut DashPayProfileFFI, + ) + }; + if take_pwffi_error(env, result) { + // The FFI zero-initializes out_profile before fallible work, + // so nothing was allocated on the error path. + return ptr::null_mut(); + } + let json = crate::tokens::profile_to_json(&profile); + unsafe { + platform_wallet_ffi::dashpay_profile_ffi_free(&mut profile as *mut DashPayProfileFFI) + }; + new_jstring(env, json) + }) +} + +/// Set the owner-private contactInfo (alias / note / displayHidden) for +/// `(identityId, contactId)`. Local state always updates; the encrypted +/// on-chain publish is gated by DIP-15 (needs ≥ 2 established contacts) +/// and by the wallet's signing capability. Returns the +/// `CONTACT_INFO_*` outcome discriminant: 0 published, 1 deferred until +/// two contacts, 2 skipped (watch-only). Blocking when it publishes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_setContactInfo( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + contact_id: JByteArray, + alias: JString, + note: JString, + display_hidden: jboolean, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jint { + guard(&mut env, -1, |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return -1; + }; + let Some(contact) = read_id32(env, &contact_id, "contactId") else { + return -1; + }; + let alias_c = opt_jstring_to_cstring(env, &alias); + let note_c = opt_jstring_to_cstring(env, ¬e); + + let mut outcome: u8 = 0; + let result = unsafe { + platform_wallet_ffi::contact_info::platform_wallet_set_dashpay_contact_info_with_signer( + wallet_handle as Handle, + id.as_ptr(), + contact.as_ptr(), + alias_c.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + note_c.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + display_hidden != 0, + signer_handle as *mut SignerHandle, + core_signer_handle as *mut MnemonicResolverHandle, + &mut outcome as *mut u8, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + outcome as jint + }) +} + +// ── Signer capability preflight ─────────────────────────────────────── + +/// Whether the mnemonic resolver can derive-sign identity keys of +/// `keyType` (bridges `dash_sdk_resolver_supports_key_type`). Preflight +/// only — keeps `canSignWith` consistent with the sign path's +/// `UNSUPPORTED_KEY_TYPE` rejection by reading the one Rust source of +/// truth (Swift `KeychainSigner.resolverCanDeriveSign`). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_resolverSupportsKeyType( + mut env: JNIEnv, + _class: JClass, + key_type: jint, +) -> jboolean { + guard(&mut env, 0, |_| { + let Ok(kt) = u8::try_from(key_type) else { + return 0; + }; + let supported = unsafe { platform_wallet_ffi::dash_sdk_resolver_supports_key_type(kt) }; + supported as jboolean + }) +} + +/// Convert a nullable JString into an owned CString; None on null / +/// conversion failure (treated as "field absent"). +fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option { + if s.is_null() { + return None; + } + let value: String = env.get_string(s).ok()?.into(); + std::ffi::CString::new(value).ok() +} diff --git a/packages/rs-unified-sdk-jni/src/mnemonic.rs b/packages/rs-unified-sdk-jni/src/mnemonic.rs index d689edabefa..36f6de39d4c 100644 --- a/packages/rs-unified-sdk-jni/src/mnemonic.rs +++ b/packages/rs-unified-sdk-jni/src/mnemonic.rs @@ -7,7 +7,7 @@ //! which decrypts the Keystore-wrapped mnemonic on demand. use crate::support::{guard, JVM}; -use jni::objects::{GlobalRef, JClass, JObject, JString}; +use jni::objects::{GlobalRef, JClass, JObject}; use jni::sys::jlong; use jni::JNIEnv; use rs_sdk_ffi::{ @@ -23,15 +23,24 @@ const RESULT_NOT_FOUND: i32 = 1; const RESULT_BUFFER_TOO_SMALL: i32 = 2; const RESULT_OTHER: i32 = 3; +/// Sentinel returns from `NativeMnemonicBridge.resolveMnemonicInto` +/// (keep in sync with the Kotlin companion constants). +const RESOLVE_NOT_FOUND: i32 = -1; +const RESOLVE_BUFFER_TOO_SMALL: i32 = -2; + struct KotlinMnemonicCtx { bridge: GlobalRef, } /// The synchronous resolve trampoline. May fire on Tokio worker threads — -/// attaches as daemon, calls -/// `NativeMnemonicBridge.resolveMnemonic(byte[]): String`, copies the -/// UTF-8 bytes into the Rust-owned buffer, and zeroes the intermediate -/// copy. Never unwinds. +/// attaches as daemon and calls +/// `NativeMnemonicBridge.resolveMnemonicInto(byte[], byte[]): int` — the +/// out-buffer contract: Kotlin writes raw UTF-8 phrase bytes into a Java +/// buffer sized to the Rust caller's capacity, this trampoline copies +/// them straight into the Rust-owned buffer and then ZEROES the Java +/// buffer. No `java.lang.String` of the phrase ever exists (an immutable +/// String can't be scrubbed and would sit in the JVM heap, recoverable +/// from a heap dump, until — if ever — collected). Never unwinds. unsafe extern "C" fn resolve_trampoline( ctx: *const c_void, wallet_id_bytes: *const u8, @@ -54,40 +63,38 @@ unsafe extern "C" fn resolve_trampoline( match env.with_local_frame(16, |env| -> Result { let wallet_id = std::slice::from_raw_parts(wallet_id_bytes, 32); let jwallet_id = env.byte_array_from_slice(wallet_id)?; - let value = env + // Reserve one byte of the Rust capacity for the NUL terminator. + let usable = out_capacity.saturating_sub(1); + let jout = env.new_byte_array(usable as i32)?; + let written = env .call_method( ctx.bridge.as_obj(), - "resolveMnemonic", - "([B)Ljava/lang/String;", - &[(&jwallet_id).into()], + "resolveMnemonicInto", + "([B[B)I", + &[(&jwallet_id).into(), (&jout).into()], )? - .l()?; - if value.is_null() { - return Ok(RESULT_NOT_FOUND); - } - - let jstr = JString::from(value); - let java_str = env.get_string(&jstr)?; - let mut bytes = java_str.to_bytes().to_vec(); - drop(java_str); + .i()?; - let code = if bytes.len() + 1 > out_capacity { - RESULT_BUFFER_TOO_SMALL - } else { - std::ptr::copy_nonoverlapping( - bytes.as_ptr(), - out_mnemonic_utf8 as *mut u8, - bytes.len(), - ); - *(out_mnemonic_utf8.add(bytes.len())) = 0; - *out_len = bytes.len(); - RESULT_OK + let code = match written { + RESOLVE_NOT_FOUND => RESULT_NOT_FOUND, + RESOLVE_BUFFER_TOO_SMALL => RESULT_BUFFER_TOO_SMALL, + len if len < 0 || len as usize > usable => RESULT_OTHER, + len => { + let len = len as usize; + // Copy the phrase bytes straight from the Java buffer + // into the Rust-owned out buffer, then scrub the Java + // buffer — the only JVM-side plaintext copy. + // `.cast()`: c_char is i8 on x86_64 but u8 on + // aarch64-linux-android; jbyte is always i8. + let dst = std::slice::from_raw_parts_mut(out_mnemonic_utf8.cast::(), len); + env.get_byte_array_region(&jout, 0, dst)?; + *(out_mnemonic_utf8.add(len)) = 0; + *out_len = len; + RESULT_OK + } }; - - // Zero the intermediate Rust copy of the phrase. (The JVM String - // itself is garbage-collected — same residual exposure the iOS - // Swift String has.) - bytes.iter_mut().for_each(|b| *b = 0); + let zeros = vec![0i8; usable]; + env.set_byte_array_region(&jout, 0, &zeros)?; Ok(code) }) { Ok(code) => code, diff --git a/packages/rs-unified-sdk-jni/src/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index 44b32e41241..d5309636471 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -30,6 +30,14 @@ use std::ffi::{c_void, CString}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; +/// Zero a [`CString`]'s backing buffer before releasing it. For buffers +/// holding key material (the mnemonic) — a plain drop would return the +/// plaintext bytes to the allocator intact. +fn scrub_cstring(c: CString) { + let mut bytes = c.into_bytes(); + bytes.iter_mut().for_each(|b| *b = 0); +} + struct KotlinSignerCtx { bridge: GlobalRef, } @@ -359,13 +367,24 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith }; // Interior NULs would truncate the C string mid-derivation-path; - // reject rather than silently sign under a wrong path. - let (Ok(mnemonic_c), Ok(path_c)) = (CString::new(mnemonic_str), CString::new(path_str)) - else { + // reject rather than silently sign under a wrong path. The + // mnemonic buffer is scrubbed on every path — including the + // NulError one, which hands the allocation back via into_vec(). + let mnemonic_c = match CString::new(mnemonic_str) { + Ok(c) => c, + Err(e) => { + let mut leaked = e.into_vec(); + leaked.iter_mut().for_each(|b| *b = 0); + crate::support::throw_sdk_exception(env, 1, "mnemonic contained an interior NUL"); + return ptr::null_mut(); + } + }; + let Ok(path_c) = CString::new(path_str) else { + scrub_cstring(mnemonic_c); crate::support::throw_sdk_exception( env, 1, - "mnemonic or derivation path contained an interior NUL", + "derivation path contained an interior NUL", ); return ptr::null_mut(); }; @@ -401,6 +420,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith ) }; + // The actual secret this function holds is the mnemonic, not the + // signature — scrub it before either return path. (The JVM-side + // String the caller passed remains the pre-existing exposure; + // tracked as the KeystoreSigner follow-up.) + scrub_cstring(mnemonic_c); + if rc != 0 || err_tag != SIGN_WITH_MNEMONIC_OK { crate::support::throw_sdk_exception( env, From 8953af4e3ea46ba62d77717cc4410e01166ed89b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:13:43 +0700 Subject: [PATCH 05/12] fix(kotlin-sdk): fold K2 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-lens review of f0ebdfc37b (mnemonic security, concurrency/ lifecycle, JNI bridge correctness) — two must-fixes and the actionable should-fixes folded: - Drain use-after-free (concurrency must-fix): the background drain now creates its OWN resolver + signer, owned by the coroutine and closed in its finally (the Swift Task.detached + withExtendedLifetime shape). Borrowing the manager's shared children risked dereferencing freed vtables when close()/manager-swap raced a slow blocking drain — resolver/signer handles are raw boxes, not storage-keyed like wallet handles. - dashpay.rs read_id32 threw through DashSDKException's non-existent (String) constructor → NoSuchMethodError instead of a DashSdkError (or a silent guard-default: a malformed contactId reported the benign DEFERRED outcome). Now throw_sdk_exception + null guard, matching the sibling readers. - signer.rs seed scrubbing completed: non-secret args decode first so a sibling failure can't orphan a decoded phrase, and reserve_exact(1) before CString::new prevents the NUL-append realloc from freeing the original plaintext allocation unscrubbed. - Drain re-entrancy guard is now a single atomic false→true transition on the status flow (the separate check-then-set let two concurrent unlocks stack drains). - loadPersistedWallets publishes each wallet into [wallets] BEFORE unlocking (Swift's ordering) — the 1 Hz poll's stale-key prune could otherwise silently drop a just-published seedMismatch; the status poll now also starts on load (banner state no longer depends on the sweep service running), and stopDashPaySync leaves it running. - RESOLVE_OTHER (-3) channel added across bridge/trampoline/resolver: a Keystore/decrypt failure is no longer reported as 'no seed stored' (the iOS .other discipline); zero-capacity out buffer rejected up front (removes a 1-byte OOB NUL edge, unreachable via the real caller). - Wording/marker fixes: 'wrong seed can never sign' scoped to network-valid signatures (enforcement is on-chain key ownership, not a signer gate); the String-shaped platform-address signing path is marked as the tracked residual exposure Kotlin-side; interior-NUL clear-vs-truncate divergence documented. Gates: cargo check/clippy/fmt clean; JVM 28/28; instrumented compile green (CI emulator executes). Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/MnemonicNative.kt | 9 ++ .../dashsdk/security/KeystoreSigner.kt | 7 ++ .../security/MnemonicResolverAndPersister.kt | 8 +- .../dashsdk/wallet/PlatformWalletManager.kt | 93 +++++++++++++------ packages/rs-unified-sdk-jni/src/dashpay.rs | 18 +++- packages/rs-unified-sdk-jni/src/mnemonic.rs | 11 ++- packages/rs-unified-sdk-jni/src/signer.rs | 43 +++++---- 7 files changed, 135 insertions(+), 54 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt index 4bb65e55f4a..f0a6f8c19ac 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt @@ -39,6 +39,15 @@ abstract class NativeMnemonicBridge { /** The phrase does not fit in the caller-supplied buffer. */ const val RESOLVE_BUFFER_TOO_SMALL: Int = -2 + + /** + * A mnemonic IS stored but could not be produced — Keystore + * locked, decrypt failure, etc. Distinct from [RESOLVE_NOT_FOUND] + * so a transient failure is never reported as "this wallet has no + * seed" (the iOS `.other` channel; collapsing them points the + * user toward the wrong remediation). + */ + const val RESOLVE_OTHER: Int = -3 } /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 80e9c3b109c..71332a124f6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt @@ -148,6 +148,13 @@ class KeystoreSigner( ) return } + // KNOWN residual seed exposure: this materializes the phrase as an + // un-scrubbable JVM String and passes it across JNI — the exact + // anti-pattern the resolver path eliminated with + // resolveMnemonicInto. Migrating this call to an out-buffer + // signWithMnemonicAndPathInto variant is the tracked follow-up; + // until then every on-demand platform-address signature re-exposes + // the phrase on the JVM heap. val mnemonic = storage.retrieveMnemonic(row.walletId) if (mnemonic == null) { SignerNative.completeSign( diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt index e4c0e2736e1..491629454d3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt @@ -54,9 +54,11 @@ class MnemonicResolverAndPersister( } } } catch (_: Exception) { - // Contract: never throw across JNI; NOT_FOUND surfaces as a - // wallet-operation error on the Rust side. - RESOLVE_NOT_FOUND + // Contract: never throw across JNI. A decrypt/Keystore failure on + // a wallet that MAY have a stored seed is OTHER, not NOT_FOUND — + // reporting it as "no seed (watch-only)" would misdirect recovery + // (the iOS `.other` discipline). + RESOLVE_OTHER } override fun close() { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index e5b691edb35..e087f108ab7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -79,7 +79,7 @@ class PlatformWalletManager( val network: Network, private val database: DashDatabase, private val walletStorage: WalletStorage, - biometricGate: BiometricGate? = null, + private val biometricGate: BiometricGate? = null, ) : AutoCloseable { init { @@ -471,7 +471,6 @@ class PlatformWalletManager( .filter { it.size == 32 } val restored = ArrayList(ids.size) - val additions = LinkedHashMap() for (walletId in ids) { val handle = try { mapNativeErrors { WalletManagerNative.getWallet(managerHandle, walletId) } @@ -481,7 +480,12 @@ class PlatformWalletManager( } val managed = ManagedPlatformWallet(handle = handle, walletId = walletId) restored.add(managed) - additions[walletId.toHex()] = managed + // Publish into [wallets] BEFORE unlocking (Swift's per-wallet + // ordering): the unlock writes onto [dashPayUnlockStatus], and + // the 1 Hz poll prunes status keys absent from [wallets] — a + // publish-after-unlock ordering would let the prune silently + // drop a just-published seedMismatch. + _wallets.update { it + (walletId.toHex() to managed) } // Seedless unlock of the just-restored external-signable wallet: // verify the Keystore-resolved seed binds to this wallet and @@ -495,14 +499,15 @@ class PlatformWalletManager( try { unlockWalletFromKeystore(managed) } catch (_: Exception) { - // Logged inside (seedMismatch published on the status flow); - // one wallet's unlock failure can't fail the whole restore — - // the wallet simply stays external-signable (cannot sign). + // Outcome is published on [dashPayUnlockStatus] (seedMismatch + // for a binding rejection); one wallet's unlock failure can't + // fail the whole restore — the wallet simply stays + // external-signable. } } - if (additions.isNotEmpty()) { - _wallets.update { it + additions } - } + // Banner state (pendingAccountBuilds) must refresh whenever wallets + // exist, independent of whether the sweep service was started. + if (restored.isNotEmpty()) startDashPayStatusPolling() restored } @@ -1196,10 +1201,13 @@ class PlatformWalletManager( startDashPayStatusPolling() } - /** Stop the recurring sweep (restartable) and its status poll. */ + /** + * Stop the recurring sweep (restartable). The 1 Hz status poll keeps + * running — it also serves the unlock banner (`pendingAccountBuilds`), + * which must stay fresh while wallets exist regardless of the sweep; + * it dies with the manager scope in [close]. + */ suspend fun stopDashPaySync() = withContext(Dispatchers.IO) { - dashPayPollJob?.cancel() - dashPayPollJob = null mapNativeErrors { DashpayNative.dashPaySyncStop(managerHandle) } _dashPaySyncIsSyncing.value = false } @@ -1253,8 +1261,10 @@ class PlatformWalletManager( * 1. **Verify** the Keystore-resolved seed binds to this wallet * (derives the BIP44 account-0 xpub through the resolver and * compares with the persisted one) — a mis-mapped Keystore slot - * fails here and the wallet stays external-signable, so a wrong - * seed can never sign. + * fails here, no drain runs, and the wallet stays + * external-signable, so a wrong seed can never produce a + * network-valid signature for it (the enforcement is on-chain key + * ownership — the signer itself is not gated on this verify). * 2. **Drain** deferred contact-crypto in the background (network + * ECDH per entry). Guarded against stacking on an in-flight drain. * @@ -1298,28 +1308,47 @@ class PlatformWalletManager( // Don't stack a second drain on an in-flight one: a banner Unlock // tap while a drain runs would duplicate the network re-fetch + - // ECDH work and race the channel-broken writes. - if (_dashPayUnlockStatus.value[key]?.draining == true) return true - updateUnlockStatus(key) { it.copy(draining = true) } + // ECDH work and race the channel-broken writes. The false→true + // transition happens inside ONE atomic flow update — a separate + // check-then-set would let two concurrent unlocks (auto-unlock + // racing a banner tap) both pass the check and stack drains. + var wonDrainSlot = false + _dashPayUnlockStatus.update { map -> + val prev = map[key] ?: DashPayUnlockStatus() + if (prev.draining) { + wonDrainSlot = false + map + } else { + wonDrainSlot = true + map + (key to prev.copy(draining = true)) + } + } + if (!wonDrainSlot) return true // Drain in the background — it re-fetches and decrypts over the - // network, so it must not block the caller. [signer] and - // [mnemonicResolver] are manager members (strongly reachable for - // the manager's lifetime), so their bridge GlobalRefs outlive the - // call. The raw wallet handle is captured, not the wrapper: a - // wallet destroyed before the drain runs just misses Rust-side - // (NotFound) — no use-after-free. An auth-gated signing failure - // inside the drain (Android-only: identity keys are biometric- - // gated here, unlike iOS) leaves the entry queued — the sweep - // self-heals — and surfaces like any other drain error. + // network, so it must not block the caller. The drain gets its OWN + // resolver + signer, owned by this coroutine (the Swift + // Task.detached + withExtendedLifetime shape): the manager's shared + // children are freed by close() without waiting for an in-flight + // blocking JNI call, so borrowing them would be a use-after-free + // when a manager swap races a slow drain. The raw wallet handle is + // captured, not the wrapper: a wallet destroyed before the drain + // runs just misses Rust-side (NotFound) — wallet handles are + // storage-keyed, unlike the resolver/signer boxes. An auth-gated + // signing failure inside the drain (Android-only: identity keys + // are biometric-gated here, unlike iOS) leaves the entry queued — + // the sweep self-heals — and surfaces like any other drain error. val walletHandle = managed.handle scope.launch(Dispatchers.IO) { + val drainResolver = MnemonicResolverAndPersister(walletStorage) + val drainSigner = + KeystoreSigner(walletStorage, network, biometricGate, database.platformAddressDao()) try { mapNativeErrors { DashpayNative.drainPendingContactCrypto( walletHandle, - signer.nativeHandle, - mnemonicResolver.nativeHandle, + drainSigner.nativeHandle, + drainResolver.nativeHandle, ) } } catch (_: Exception) { @@ -1327,6 +1356,8 @@ class PlatformWalletManager( // next unlock) re-attempts; the queue rebuilds via the sweep. } finally { updateUnlockStatus(key) { it.copy(draining = false) } + runCatching { drainResolver.close() } + runCatching { drainSigner.close() } } } return true @@ -1448,8 +1479,10 @@ data class DashPayUnlockStatus( val draining: Boolean = false, /** * The stored seed failed the binding verify — a mis-mapped Keystore - * slot. Security-relevant: the wallet stays external-signable, so the - * wrong seed can never sign for it. + * slot. Security-relevant: no drain runs and the wallet stays + * external-signable, so the wrong seed can never produce a + * network-valid signature for it (its identity keys derive from the + * correct seed; a foreign-seed signature fails on-chain validation). */ val seedMismatch: Boolean = false, /** Deferred contact-crypto entries queued (from the 1 Hz poll). */ diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index cbab4b6c0f9..34ce31b6db4 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -30,11 +30,19 @@ use std::os::raw::c_char; use std::ptr; fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + // throw_sdk_exception hits DashSDKException's (Int, String) ctor — a + // bare throw_new would look for a (String) ctor that does not exist + // and surface as NoSuchMethodError instead of a DashSdkError. + if arr.is_null() { + crate::support::throw_sdk_exception(env, 1, &format!("{field} must not be null")); + return None; + } let len = env.get_array_length(arr).ok()? as usize; if len != 32 { - let _ = env.throw_new( - "org/dashfoundation/dashsdk/ffi/DashSDKException", - format!("{field} must be 32 bytes, got {len}"), + crate::support::throw_sdk_exception( + env, + 1, + &format!("{field} must be 32 bytes, got {len}"), ); return None; } @@ -789,7 +797,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_resolve } /// Convert a nullable JString into an owned CString; None on null / -/// conversion failure (treated as "field absent"). +/// conversion failure (treated as "field absent"). NOTE: an interior NUL +/// therefore CLEARS the field on Android (Swift truncates at the NUL +/// instead) — a deliberate divergence for pathological input. fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option { if s.is_null() { return None; diff --git a/packages/rs-unified-sdk-jni/src/mnemonic.rs b/packages/rs-unified-sdk-jni/src/mnemonic.rs index 36f6de39d4c..2e3778868bb 100644 --- a/packages/rs-unified-sdk-jni/src/mnemonic.rs +++ b/packages/rs-unified-sdk-jni/src/mnemonic.rs @@ -27,6 +27,7 @@ const RESULT_OTHER: i32 = 3; /// (keep in sync with the Kotlin companion constants). const RESOLVE_NOT_FOUND: i32 = -1; const RESOLVE_BUFFER_TOO_SMALL: i32 = -2; +const RESOLVE_OTHER: i32 = -3; struct KotlinMnemonicCtx { bridge: GlobalRef, @@ -60,11 +61,18 @@ unsafe extern "C" fn resolve_trampoline( return RESULT_OTHER; }; + // A zero-capacity out buffer cannot even hold the NUL terminator; + // reject up front (the `usable = cap - 1` arithmetic below would + // otherwise let an empty phrase write one byte out of bounds). + if out_capacity == 0 { + return RESULT_BUFFER_TOO_SMALL; + } + match env.with_local_frame(16, |env| -> Result { let wallet_id = std::slice::from_raw_parts(wallet_id_bytes, 32); let jwallet_id = env.byte_array_from_slice(wallet_id)?; // Reserve one byte of the Rust capacity for the NUL terminator. - let usable = out_capacity.saturating_sub(1); + let usable = out_capacity - 1; let jout = env.new_byte_array(usable as i32)?; let written = env .call_method( @@ -78,6 +86,7 @@ unsafe extern "C" fn resolve_trampoline( let code = match written { RESOLVE_NOT_FOUND => RESULT_NOT_FOUND, RESOLVE_BUFFER_TOO_SMALL => RESULT_BUFFER_TOO_SMALL, + RESOLVE_OTHER => RESULT_OTHER, len if len < 0 || len as usize > usable => RESULT_OTHER, len => { let len = len as usize; diff --git a/packages/rs-unified-sdk-jni/src/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index d5309636471..22361a31e3b 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -356,8 +356,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith data: JByteArray, ) -> jbyteArray { guard(&mut env, ptr::null_mut(), |env| { - let (Ok(mnemonic_str), Ok(path_str), Ok(payload)) = ( - env.get_string(&mnemonic).map(String::from), + // Decode the non-secret arguments FIRST, the mnemonic LAST: a + // sibling-conversion failure must never orphan an already-decoded + // plaintext copy of the phrase. + let (Ok(path_str), Ok(payload)) = ( env.get_string(&derivation_path).map(String::from), env.convert_byte_array(&data), ) else { @@ -365,29 +367,38 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith crate::support::throw_sdk_exception(env, 1, "invalid mnemonic-sign arguments"); return ptr::null_mut(); }; + let Ok(path_c) = CString::new(path_str) else { + // Interior NULs would truncate the C string mid-derivation-path; + // reject rather than silently sign under a wrong path. + crate::support::throw_sdk_exception( + env, + 1, + "derivation path contained an interior NUL", + ); + return ptr::null_mut(); + }; + let Ok(mnemonic_str) = env.get_string(&mnemonic).map(String::from) else { + let _ = env.exception_clear(); + crate::support::throw_sdk_exception(env, 1, "invalid mnemonic argument"); + return ptr::null_mut(); + }; - // Interior NULs would truncate the C string mid-derivation-path; - // reject rather than silently sign under a wrong path. The - // mnemonic buffer is scrubbed on every path — including the - // NulError one, which hands the allocation back via into_vec(). - let mnemonic_c = match CString::new(mnemonic_str) { + // The mnemonic buffer is scrubbed on every path. `reserve_exact(1)` + // BEFORE `CString::new` matters: the NUL append would otherwise + // reallocate a capacity==len buffer, freeing the original plaintext + // allocation unscrubbed — the scrub would then only touch the copy. + let mut mnemonic_bytes = mnemonic_str.into_bytes(); + mnemonic_bytes.reserve_exact(1); + let mnemonic_c = match CString::new(mnemonic_bytes) { Ok(c) => c, Err(e) => { + // NulError hands back the original allocation via into_vec. let mut leaked = e.into_vec(); leaked.iter_mut().for_each(|b| *b = 0); crate::support::throw_sdk_exception(env, 1, "mnemonic contained an interior NUL"); return ptr::null_mut(); } }; - let Ok(path_c) = CString::new(path_str) else { - scrub_cstring(mnemonic_c); - crate::support::throw_sdk_exception( - env, - 1, - "derivation path contained an interior NUL", - ); - return ptr::null_mut(); - }; let network = match network { 0 => dash_network::ffi::FFINetwork::Mainnet, From e9ff846d52b4f8c73facc44629ee6110e17b43b3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:20:57 +0700 Subject: [PATCH 06/12] feat(kotlin-sdk): bridge the DIP-15 auto-accept QR pair (K3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_build_auto_accept_qr and platform_wallet_send_contact_request_from_qr, first consumed by the K3 DashPay tab (profile QR display + scan-to-send in AddContact) — the last two of the migration's bridged exports. Kotlin surface: Dashpay.buildAutoAcceptQr / sendContactRequestFromQr (returns a ContactRequestRef). QR generation/parsing/proof crypto is entirely Rust-side (auto_accept.rs); the app only renders/scans the URI. Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/DashpayNative.kt | 31 +++++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 38 +++++++++ packages/rs-unified-sdk-jni/src/dashpay.rs | 82 +++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index b4f0fe06939..1bc1bb1a3b7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -173,4 +173,35 @@ internal object DashpayNative { * ← Swift `KeychainSigner.resolverCanDeriveSign`. */ external fun resolverSupportsKeyType(keyType: Int): Boolean + + // ── DIP-15 auto-accept QR ───────────────────────────────────────── + + /** + * Build the owner's DIP-15 auto-accept QR payload + * (`dash:?du=…&dapk=…`) for [identityId], keying the proof through + * [coreSignerHandle]. [username] is a display hint (nullable). + * ← Swift `ManagedPlatformWallet.buildAutoAcceptQR`. + */ + external fun buildAutoAcceptQr( + walletHandle: Long, + identityId: ByteArray, + username: String?, + coreSignerHandle: Long, + ): String? + + /** + * Scan-to-send: parse a DIP-15 auto-accept QR [uri] and send the + * contact request it describes from [senderIdentityId] (the embedded + * proof key lets the owner auto-accept). Blocking (network). Returns + * the created `ContactRequest` handle — destroy via + * [TokensNative.contactRequestDestroy]. + * ← Swift `ManagedPlatformWallet.sendContactRequestFromQR`. + */ + external fun sendContactRequestFromQr( + walletHandle: Long, + senderIdentityId: ByteArray, + uri: String, + signerHandle: Long, + coreSignerHandle: Long, + ): Long } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 3961eb73f65..d140cd1c558 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -319,6 +319,44 @@ class Dashpay internal constructor(private val walletHandle: Long) { mapNativeErrors { DashpayNative.searchDpnsNames(walletHandle, prefix, limit) } } + // ── DIP-15 auto-accept QR (upstream #3841 parity) ────────────────── + + /** + * Build the owner's DIP-15 auto-accept QR URI for [identityId] + * (`dash:?du=…&dapk=…`), keying the proof through [coreSignerHandle]. + * The UI renders it as a QR (ZXing). ← Swift `buildAutoAcceptQR`. + */ + suspend fun buildAutoAcceptQr( + identityId: ByteArray, + username: String? = null, + coreSignerHandle: Long, + ): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.buildAutoAcceptQr(walletHandle, identityId, username, coreSignerHandle) + } + } + + /** + * Scan-to-send: parse an auto-accept QR [uri] and send the contact + * request it describes from [senderIdentityId]. Blocking network + * call; runs on IO. Returns the created request wrapped as a + * [ContactRequestRef] — close it (or `use {}`) when done. + * ← Swift `sendContactRequestFromQR`. + */ + suspend fun sendContactRequestFromQr( + senderIdentityId: ByteArray, + uri: String, + signerHandle: Long, + coreSignerHandle: Long, + ): ContactRequestRef = withContext(Dispatchers.IO) { + val handle = mapNativeErrors { + DashpayNative.sendContactRequestFromQr( + walletHandle, senderIdentityId, uri, signerHandle, coreSignerHandle, + ) + } + ContactRequestRef(handle) + } + // ── Profile / contactInfo writes (upstream #3841 parity) ────────── /** diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 34ce31b6db4..ce64501ca93 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -807,3 +807,85 @@ fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let username_c = opt_jstring_to_cstring(env, &username); + let mut uri: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_build_auto_accept_qr( + wallet_handle as Handle, + id.as_ptr(), + username_c.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + core_signer_handle as *mut MnemonicResolverHandle, + &mut uri as *mut *mut c_char, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let value = unsafe { opt_cstr(uri) }.unwrap_or_default(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(uri) }; + new_jstring(env, value) + }) +} + +/// Scan-to-send: parse a DIP-15 auto-accept QR `uri` and send the +/// contact request it describes from `senderIdentityId` (bridges +/// `platform_wallet_send_contact_request_from_qr`) — the recipient's +/// embedded proof key lets the owner auto-accept. Blocking (network). +/// Returns the created `ContactRequest` handle (destroy via +/// `TokensNative.contactRequestDestroy`). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendContactRequestFromQr( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + sender_identity_id: JByteArray, + uri: JString, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + let Some(id) = read_id32(env, &sender_identity_id, "senderIdentityId") else { + return 0; + }; + let Some(uri_c) = opt_jstring_to_cstring(env, &uri) else { + crate::support::throw_sdk_exception(env, 1, "uri must not be null"); + return 0; + }; + let mut request_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_send_contact_request_from_qr( + wallet_handle as Handle, + id.as_ptr(), + uri_c.as_ptr(), + signer_handle as *mut SignerHandle, + core_signer_handle as *mut MnemonicResolverHandle, + &mut request_handle as *mut Handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + request_handle as jlong + }) +} From 7b2b701b1c46673a4045e73a2d2120e66457aff8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:27:07 +0700 Subject: [PATCH 07/12] feat(kotlin-sdk): DashPay tab navigation + contact meta infra (K3 slice A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the KotlinExampleApp bottom tabs to make DashPay first-class, mirroring the iOS ContentView.swift RootTab (SYNC, WALLETS, IDENTITIES, DASHPAY, SETTINGS), and land the device-local contact-meta + avatar infra the DashPay screens (slices B/C) build on. UI hub itself is a placeholder here. - Coil: add io.coil-kt:coil-compose (the app's one approved new dependency) for avatar loading — libs.versions.toml + app build.gradle.kts. - Navigation: RootTab CONTRACTS -> DASHPAY (route DashPayHome, testTag rootTab.dashpay, Group icon, "DashPay" label). Contracts is demoted into Settings' Platform section (first row, testTag settings.contracts, navigating the shared ContractsHome route) exactly as iOS OptionsView hosts it. Register a placeholder DashPayTabScreen (testTag dashpay.tab.root) so navigation compiles; slice B replaces it. - Retire FriendsScreen: delete the screen, its Friends route, and repoint its only entry point (IdentityDetailScreen's DashPay row) to the DashPay tab. FriendsView.swift was deleted upstream. - DashPayContactMeta port (<- DashPayContactMeta.swift): SharedPreferences- backed DashPayContactMetaStore with the same key shape (dashpay.meta....) and a version StateFlow for Compose invalidation, the dashPayContactDisplayName precedence helper, and a Coil-backed DashPayAvatar with an initials-circle fallback. - Lifecycle: start/stop DashPay sync in AppContainer.rebindWalletScopedServices alongside the address/shielded loops, matching iOS. - Update AppSmokeTest tab list (rootTab.contracts -> rootTab.dashpay). Build gate: :app:compileDebugKotlin + :sdk:compileDebugKotlin both pass. Co-Authored-By: Claude Fable 5 --- .../KotlinExampleApp/app/build.gradle.kts | 1 + .../dashfoundation/example/AppSmokeTest.kt | 2 +- .../dashfoundation/example/di/AppContainer.kt | 8 + .../example/navigation/AppNavHost.kt | 11 +- .../example/navigation/Routes.kt | 7 +- .../dashfoundation/example/ui/MainScreen.kt | 6 +- .../example/ui/dashpay/DashPayContactMeta.kt | 172 ++++++++++ .../example/ui/dashpay/DashPayTabScreen.kt | 45 +++ .../example/ui/identity/FriendsScreen.kt | 293 ------------------ .../ui/identity/IdentityDetailScreen.kt | 11 +- .../example/ui/settings/SettingsScreen.kt | 12 +- packages/kotlin-sdk/gradle/libs.versions.toml | 2 + 12 files changed, 256 insertions(+), 314 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt delete mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts b/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts index 0a4511fe85d..b5607f9e8a9 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts +++ b/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { implementation(libs.camera.view) implementation(libs.mlkit.barcode.scanning) implementation(libs.zxing.core) + implementation(libs.coil.compose) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt index d53585acdbe..557d6374ec2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt @@ -34,7 +34,7 @@ class AppSmokeTest { listOf( "rootTab.wallets", "rootTab.identities", - "rootTab.contracts", + "rootTab.dashpay", "rootTab.settings", "rootTab.sync", ).forEach { tag -> diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index 8ec645ab393..e08f79a04b5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -183,6 +183,7 @@ class AppContainer(private val context: Context) { if (shieldedService.isAvailable) { manager.stopShieldedSync() } + manager.stopDashPaySync() } catch (e: Exception) { android.util.Log.w(TAG, "Failed to stop sync coordinators", e) } @@ -214,6 +215,13 @@ class AppContainer(private val context: Context) { if (shieldedService.isAvailable && !manager.isShieldedSyncRunning()) { manager.startShieldedSync() } + // DashPay contact/profile/payment sync — the load-bearing + // prerequisite for the DashPay tab (← iOS + // rebindWalletScopedServices starting it alongside the + // address/shielded loops on the wallet-present branch). + if (!manager.isDashPaySyncRunning()) { + manager.startDashPaySync() + } } catch (e: Exception) { android.util.Log.e(TAG, "Failed to bind wallet-scoped services", e) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt index 36dad028f7e..49cd458271b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt @@ -29,8 +29,8 @@ import org.dashfoundation.example.ui.diagnostics.WalletMemoryExplorerScreen import org.dashfoundation.example.ui.identity.AddIdentityKeyScreen import org.dashfoundation.example.ui.identity.ContestDetailScreen import org.dashfoundation.example.ui.identity.CreateIdentityScreen +import org.dashfoundation.example.ui.dashpay.DashPayTabScreen import org.dashfoundation.example.ui.identity.DpnsTestScreen -import org.dashfoundation.example.ui.identity.FriendsScreen import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen import org.dashfoundation.example.ui.identity.IdentityDetailScreen import org.dashfoundation.example.ui.identity.KeyDetailScreen @@ -166,11 +166,6 @@ fun AppNavHost( SelectMainNameScreen(route.identityIdHex, navController) } - composable { entry -> - val route = entry.toRoute() - FriendsScreen(route.identityIdHex, navController) - } - composable { entry -> val route = entry.toRoute() KeysListScreen(route.identityIdHex, navController) @@ -380,6 +375,10 @@ fun AppNavHost( CoSignProposalScreen(entry.toRoute(), navController) } + // ── DashPay graph ────────────────────────────────────────────── + + composable { DashPayTabScreen(navController) } + // ── Diagnostics graph ────────────────────────────────────────── composable { AddressQueriesScreen(navController) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt index f9627744f9c..2829d7cb5fb 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt @@ -18,6 +18,8 @@ import kotlinx.serialization.Serializable @Serializable object ContractsHome +@Serializable object DashPayHome + @Serializable object SettingsHome // ── Settings graph ───────────────────────────────────────────────────── @@ -89,9 +91,6 @@ import kotlinx.serialization.Serializable /** Pick the identity's main DPNS name (← `SelectMainNameView.swift`). */ @Serializable data class SelectMainName(val identityIdHex: String) -/** DashPay contacts for an identity (← `FriendsView.swift`). */ -@Serializable data class Friends(val identityIdHex: String) - /** All public keys of an identity (← `KeysListView.swift`). */ @Serializable data class KeysList(val identityIdHex: String) @@ -356,6 +355,6 @@ enum class RootTab( SYNC(SyncHome, SyncHome::class, "rootTab.sync"), WALLETS(WalletsHome, WalletsHome::class, "rootTab.wallets"), IDENTITIES(IdentitiesHome, IdentitiesHome::class, "rootTab.identities"), - CONTRACTS(ContractsHome, ContractsHome::class, "rootTab.contracts"), + DASHPAY(DashPayHome, DashPayHome::class, "rootTab.dashpay"), SETTINGS(SettingsHome, SettingsHome::class, "rootTab.settings"), } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt index 7340d9924a8..8a7b4213bc2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBalanceWallet -import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync @@ -95,7 +95,7 @@ private val RootTab.icon: ImageVector RootTab.SYNC -> Icons.Default.Sync RootTab.WALLETS -> Icons.Default.AccountBalanceWallet RootTab.IDENTITIES -> Icons.Default.Person - RootTab.CONTRACTS -> Icons.Default.Description + RootTab.DASHPAY -> Icons.Default.Group RootTab.SETTINGS -> Icons.Default.Settings } @@ -104,6 +104,6 @@ private val RootTab.label: String RootTab.SYNC -> "Sync" RootTab.WALLETS -> "Wallets" RootTab.IDENTITIES -> "Identities" - RootTab.CONTRACTS -> "Contracts" + RootTab.DASHPAY -> "DashPay" RootTab.SETTINGS -> "Settings" } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt new file mode 100644 index 00000000000..3941ea10539 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt @@ -0,0 +1,172 @@ +package org.dashfoundation.example.ui.dashpay + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.example.util.toHex + +/** + * Device-local, per-contact metadata for the DashPay tab — port of + * `DashPayContactMeta.swift`'s `DashPayContactMetaStore`: alias, note, + * hidden flag, and a DPNS-label hint captured at add time. + * + * These are scoped to "This device only" — a later milestone replaces + * this store with `contactInfo` documents synced via Platform. Until + * then [SharedPreferences] is the honest backing (no sync semantics), the + * counterpart of iOS `UserDefaults`. + * + * Keys are scoped by `(network, owner identity, contact identity)` so two + * owner identities (or two networks) never share a contact's alias. The + * [version] counter is bumped on every write so Compose readers that + * compute reads through this store recompose after a write — plain + * SharedPreferences reads don't participate in Compose invalidation. + */ +class DashPayContactMetaStore(context: Context) { + + private val prefs = context.getSharedPreferences("dashpay_contact_meta", Context.MODE_PRIVATE) + + /** Bumped on every write so observing composables recompute reads. */ + private val _version = MutableStateFlow(0) + val version: StateFlow = _version.asStateFlow() + + // ── Alias (local display-name override) ────────────────────────────── + + fun alias(network: Network, owner: ByteArray, contact: ByteArray): String? = + nonEmpty(prefs.getString(key("alias", network, owner, contact), null)) + + fun setAlias(alias: String?, network: Network, owner: ByteArray, contact: ByteArray) { + write(nonEmpty(alias), key("alias", network, owner, contact)) + } + + // ── Note ───────────────────────────────────────────────────────────── + + fun note(network: Network, owner: ByteArray, contact: ByteArray): String? = + nonEmpty(prefs.getString(key("note", network, owner, contact), null)) + + fun setNote(note: String?, network: Network, owner: ByteArray, contact: ByteArray) { + write(nonEmpty(note), key("note", network, owner, contact)) + } + + // ── Hidden ─────────────────────────────────────────────────────────── + + fun isHidden(network: Network, owner: ByteArray, contact: ByteArray): Boolean = + prefs.getBoolean(key("hidden", network, owner, contact), false) + + fun setHidden(hidden: Boolean, network: Network, owner: ByteArray, contact: ByteArray) { + prefs.edit().putBoolean(key("hidden", network, owner, contact), hidden).apply() + _version.value += 1 + } + + // ── DPNS hint ──────────────────────────────────────────────────────── + + /** + * DPNS label observed when the contact was added via username search. + * Display-precedence fallback only — contacts' DPNS labels aren't + * persisted in Room (only managed identities' are), so this hint is + * "the data available" for the contact rows. + */ + fun dpnsHint(network: Network, owner: ByteArray, contact: ByteArray): String? = + nonEmpty(prefs.getString(key("dpnsHint", network, owner, contact), null)) + + fun setDpnsHint(name: String?, network: Network, owner: ByteArray, contact: ByteArray) { + write(nonEmpty(name), key("dpnsHint", network, owner, contact)) + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private fun key(field: String, network: Network, owner: ByteArray, contact: ByteArray): String = + "dashpay.meta.$field.${network.ffiValue}.${owner.toHex()}.${contact.toHex()}" + + private fun write(value: String?, key: String) { + prefs.edit().apply { + if (value != null) putString(key, value) else remove(key) + }.apply() + _version.value += 1 + } + + private fun nonEmpty(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } +} + +// ── Display-name precedence ────────────────────────────────────────────── + +/** + * Resolve the display precedence for a DashPay contact — port of + * `dashPayContactDisplayName` in `DashPayContactMeta.swift`: local alias → + * DashPay profile `displayName` → DPNS label → truncated hex id. Every + * input but the id is optional; blank/whitespace strings count as absent. + */ +fun dashPayContactDisplayName( + contactId: ByteArray, + alias: String?, + profileDisplayName: String?, + dpnsLabel: String?, +): String { + for (candidate in listOf(alias, profileDisplayName, dpnsLabel)) { + val trimmed = candidate?.trim() + if (!trimmed.isNullOrEmpty()) return trimmed + } + return contactId.toHex().take(12) + "…" +} + +// ── Avatar ─────────────────────────────────────────────────────────────── + +/** + * Shared avatar bubble — port of `DashPayAvatarView`: the profile's + * `avatarUrl` loaded via Coil when present, an initial-circle fallback + * otherwise. The initial comes from the resolved display name; the tint is + * fixed (the same for every contact, not name-hashed), theme-aware via + * [MaterialTheme]. + */ +@Composable +fun DashPayAvatar(avatarUrl: String?, displayName: String, size: Dp = 40.dp) { + val url = avatarUrl?.trim()?.takeIf { it.isNotEmpty() } + if (url != null) { + SubcomposeAsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape), + loading = { InitialsCircle(displayName, size) }, + error = { InitialsCircle(displayName, size) }, + ) + } else { + InitialsCircle(displayName, size) + } +} + +@Composable +private fun InitialsCircle(displayName: String, size: Dp) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = displayName.take(1).uppercase(), + color = MaterialTheme.colorScheme.primary, + style = if (size > 50.dp) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.titleMedium + }, + ) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt new file mode 100644 index 00000000000..b72950db9c9 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -0,0 +1,45 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavHostController + +/** + * DashPay tab root — placeholder for the port of `DashPayTabView.swift`. + * + * The DASHPAY tab is first-class as of this milestone; the contacts / + * requests / profile hub replaces this empty state in the next slice. The + * [navController] is accepted now so the hub's child navigation attaches + * without a route re-registration. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashPayTabScreen(navController: NavHostController) { + Scaffold( + topBar = { TopAppBar(title = { Text("DashPay") }) }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .testTag("dashpay.tab.root"), + contentAlignment = Alignment.Center, + ) { + Text( + "DashPay contacts arrive in the next milestone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt deleted file mode 100644 index 64c7e9b259f..00000000000 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt +++ /dev/null @@ -1,293 +0,0 @@ -package org.dashfoundation.example.ui.identity - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import kotlinx.coroutines.launch -import org.dashfoundation.example.di.LocalAppContainer -import org.dashfoundation.example.ui.components.ErrorAlertDialog -import org.dashfoundation.example.ui.components.FormSection -import org.dashfoundation.example.ui.components.RecipientPicker -import org.dashfoundation.example.ui.components.RecipientSelection -import org.dashfoundation.example.ui.components.SubmitButton -import org.dashfoundation.example.ui.credits.rememberManagedWalletFor -import org.dashfoundation.example.util.hexToBytes -import org.dashfoundation.example.util.toHex - -/** - * DashPay contacts for an identity — port of `FriendsView.swift`. - * - * Hydration mirrors the Swift `loadFriends()` pipeline: sync incoming - * requests from the network (`ManagedPlatformWallet.dashpay.syncContactRequests`) - * + fetch sent, then read the three contact-id lists off a fresh managed- - * identity snapshot (`.contacts(identityId)`), falling back to the Room - * [org.dashfoundation.dashsdk.persistence.dao.DashpayDao] rows when the wallet - * isn't loaded or the network read fails. Accept is wired via - * `.acceptIncomingRequest` (the already-bridged accept over the incoming - * request handle); Ignore (the reversible per-sender local mute that - * replaced the old per-request Reject — Swift `ContactRequestsView`'s - * "Ignore" action) is wired via `.ignoreContactSender`. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FriendsScreen(identityIdHex: String, navController: NavHostController) { - val container = LocalAppContainer.current - val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } - val scope = rememberCoroutineScope() - - val identity by container.database.identityDao() - .observeByIdentityId(idBytes) - .collectAsStateWithLifecycle(initialValue = null) - val wallet = rememberManagedWalletFor(identity?.walletId) - - // Room fallback (populated by the platform-wallet sync persister). - val roomIncoming by container.database.dashpayDao() - .observeContactRequests(idBytes, isOutgoing = false) - .collectAsStateWithLifecycle(initialValue = emptyList()) - val roomOutgoing by container.database.dashpayDao() - .observeContactRequests(idBytes, isOutgoing = true) - .collectAsStateWithLifecycle(initialValue = emptyList()) - - // Live hydration from the managed-identity enumeration (preferred when a - // wallet is loaded). Null until the first hydrate finishes. - var incomingIds by remember { mutableStateOf?>(null) } - var outgoingIds by remember { mutableStateOf?>(null) } - var establishedIds by remember { mutableStateOf?>(null) } - - var recipient by remember { mutableStateOf(null) } - var isSending by remember { mutableStateOf(false) } - var acceptingHex by remember { mutableStateOf(null) } - var error by remember { mutableStateOf(null) } - - // Hydrate from the network + managed identity when the wallet resolves. - suspend fun hydrate() { - val w = wallet ?: return - // Sync from the network (best-effort — fall back to whatever local - // state exists, matching Swift which reads local state regardless). - runCatching { w.dashpay.syncContactRequests() } - runCatching { w.dashpay.fetchSentContactRequests(idBytes) } - runCatching { w.dashpay.contacts(idBytes) }.getOrNull()?.let { c -> - incomingIds = c.incoming - outgoingIds = c.outgoing - establishedIds = c.established - } - } - - LaunchedEffect(wallet) { - if (wallet != null) hydrate() - } - - // Effective lists: prefer the live hydration; fall back to Room rows. - val incoming = incomingIds - ?: roomIncoming.map { it.contactIdentityId } - val outgoing = outgoingIds - ?: roomOutgoing.map { it.contactIdentityId } - val established = establishedIds ?: emptyList() - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Friends") }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // ── Send a contact request (bridged) ────────────────────────── - FormSection(title = "Send Contact Request") { - val networkRaw = identity?.networkRaw - if (networkRaw == null || wallet == null) { - Text( - "Load this identity's wallet to send requests.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - RecipientPicker( - selection = recipient, - onSelectionChange = { recipient = it }, - networkRaw = networkRaw, - excludeIdentityIdHex = identityIdHex, - ) - SubmitButton( - text = "Send Request", - isLoading = isSending, - enabled = recipient != null && !isSending, - modifier = Modifier.fillMaxWidth().testTag("friends.send"), - ) { - val recipientId = recipient?.identityIdHex?.hexToBytes() - ?: return@SubmitButton - isSending = true - scope.launch { - try { - val manager = requireNotNull( - container.walletManagerStore.activeManager.value, - ) - wallet.dashpay.sendContactRequest( - senderIdentityId = idBytes, - recipientIdentityId = recipientId, - signerHandle = manager.signerHandle, - coreSignerHandle = manager.mnemonicResolverHandle, - ).close() - recipient = null - hydrate() - } catch (e: Exception) { - error = e.message ?: "Send failed" - } finally { - isSending = false - } - } - } - } - } - - // ── Incoming requests (accept + reject bridged) ─────────────── - FormSection(title = "Incoming") { - if (incoming.isEmpty()) { - EmptyRow("No incoming requests.") - } else { - incoming.forEach { senderId -> - val senderHex = senderId.toHex() - ListItem( - headlineContent = { Text(senderHex.take(16) + "…") }, - supportingContent = { Text("Wants to connect") }, - trailingContent = { - Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { - TextButton( - onClick = { - val w = wallet ?: return@TextButton - acceptingHex = senderHex - scope.launch { - try { - val manager = requireNotNull( - container.walletManagerStore - .activeManager.value, - ) - val ok = w.dashpay.acceptIncomingRequest( - ourIdentityId = idBytes, - senderId = senderId, - signerHandle = manager.signerHandle, - coreSignerHandle = - manager.mnemonicResolverHandle, - ) - if (!ok) { - error = "Request from ${senderHex.take(12)}… " + - "is not in local state — sync first." - } - hydrate() - } catch (e: Exception) { - error = e.message ?: "Accept failed" - } finally { - acceptingHex = null - } - } - }, - enabled = wallet != null && acceptingHex == null, - modifier = Modifier.testTag("friends.accept.$senderHex"), - ) { Text("Accept") } - TextButton( - onClick = { - val w = wallet ?: return@TextButton - scope.launch { - try { - // Reversible per-sender local mute (DP-06); - // replaced the old per-request reject. - w.dashpay.ignoreContactSender( - ourIdentityId = idBytes, - contactIdentityId = senderId, - ) - hydrate() - } catch (e: Exception) { - error = e.message ?: "Ignore failed" - } - } - }, - modifier = Modifier.testTag("friends.ignore.$senderHex"), - ) { Text("Ignore") } - } - }, - ) - } - } - } - - // ── Outgoing requests ───────────────────────────────────────── - FormSection(title = "Outgoing") { - if (outgoing.isEmpty()) { - EmptyRow("No outgoing requests.") - } else { - outgoing.forEach { recipientId -> - ListItem( - headlineContent = { Text(recipientId.toHex().take(16) + "…") }, - supportingContent = { Text("Request sent") }, - ) - } - } - } - - // ── Established contacts ────────────────────────────────────── - FormSection(title = "Contacts") { - if (established.isEmpty()) { - EmptyRow("No established contacts.") - } else { - established.forEach { contactId -> - ListItem( - headlineContent = { Text(contactId.toHex().take(16) + "…") }, - supportingContent = { Text("Connected") }, - modifier = Modifier.testTag("friends.contact.${contactId.toHex()}"), - ) - } - } - } - } - } - - ErrorAlertDialog(message = error, onDismiss = { error = null }) -} - -@Composable -private fun EmptyRow(text: String) { - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt index 7b3bb90b64e..8bb6b16a6ed 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt @@ -34,7 +34,7 @@ import androidx.navigation.NavHostController import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.navigation.ContestDetail -import org.dashfoundation.example.navigation.Friends +import org.dashfoundation.example.navigation.DashPayHome import org.dashfoundation.example.navigation.KeysList import org.dashfoundation.example.navigation.RegisterName import org.dashfoundation.example.navigation.SelectMainName @@ -52,7 +52,8 @@ import org.dashfoundation.example.util.hexToBytes * One identity's detail — port of `IdentityDetailView.swift`: identity info, * balance + credit actions, DPNS names (settled rows plus contested-name * rows linking into [ContestDetailScreen], with Register / Select-Main - * entries), DashPay (Friends entry), and the keys summary (View All Keys). + * entries), DashPay (opens the DashPay tab), and the keys summary + * (View All Keys). * * Contested-name rows probe each locally-known label with the bridged * `Voting.contestedResourceVoteState` read and surface the labels whose @@ -252,10 +253,10 @@ fun IdentityDetailScreen(identityIdHex: String, navController: NavHostController FormSection(title = "DashPay") { ListItem( - headlineContent = { Text("Friends") }, + headlineContent = { Text("DashPay") }, modifier = Modifier - .clickable { navController.navigate(Friends(identityIdHex)) } - .testTag("identityDetail.friends"), + .clickable { navController.navigate(DashPayHome) } + .testTag("identityDetail.dashpay"), ) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt index 7599190ddb1..942ee48a884 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt @@ -207,9 +207,17 @@ fun SettingsScreen(navController: NavHostController) { } // Platform section — mirrors OptionsView.swift's Platform - // section (Queries / State Transitions + SDK status), with - // Diagnostics as the run-all-queries entry point. + // section (Contracts / Queries / State Transitions + SDK + // status), with Diagnostics as the run-all-queries entry point. + // Contracts is demoted here from a top-level tab, matching iOS + // hosting it as the first Platform NavigationLink. FormSection(title = "Platform") { + androidx.compose.material3.TextButton( + onClick = { navController.navigate(org.dashfoundation.example.navigation.ContractsHome) }, + modifier = Modifier.testTag("settings.contracts"), + ) { + Text("Contracts") + } androidx.compose.material3.TextButton( onClick = { navController.navigate(org.dashfoundation.example.navigation.QueriesList) }, modifier = Modifier.testTag("settings.queries"), diff --git a/packages/kotlin-sdk/gradle/libs.versions.toml b/packages/kotlin-sdk/gradle/libs.versions.toml index b9a89bd7983..67d6cace340 100644 --- a/packages/kotlin-sdk/gradle/libs.versions.toml +++ b/packages/kotlin-sdk/gradle/libs.versions.toml @@ -14,6 +14,7 @@ navigationCompose = "2.9.0" camerax = "1.4.2" mlkitBarcode = "17.3.0" zxing = "3.5.3" +coil = "2.7.0" junit = "4.13.2" androidxTestExtJunit = "1.2.1" androidxTestRunner = "1.6.2" @@ -47,6 +48,7 @@ camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", versi camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-core = { group = "androidx.test", name = "core", version = "1.6.1" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" } From 5e01cc5394de7f7b27ea3870b08190a6f306f784 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:50:42 +0700 Subject: [PATCH 08/12] feat(kotlin-sdk): DashPay hub, contacts, requests and add-contact screens (K3 slice B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the four interactive DashPay screens the tab is built around, plus the JSON parsers and sync helpers the whole family shares. - DashPayJson.kt: data classes + org.json parsers for DashPayProfile, DpnsSearchResult, AccountBalance and DashPayPayment (slice C consumes the payment parser) — total/lenient, per-row failures skipped. - DashPayTabScreen.kt: replaces the slice-A placeholder. Wallet-backed identity picker (in-memory, resets on network switch), received-from- contacts balance (accountBalances typeTag 12, re-read after each sweep), the seedless-unlock banner off dashPayUnlockStatus (seed-mismatch / draining / pending-account-builds → unlockWalletFromKeystore), and EntityRow sections navigating to the per-section routes. Pull-to-refresh and a toolbar refresh both run dashPaySyncNow(). - ContactsScreen.kt: established-contacts (both-direction join, hidden excluded) from Room, searchable, avatar + display-name precedence, Hidden recovery link, pull-to-refresh. - ContactRequestsScreen.kt: incoming (Accept via acceptIncomingRequest / Ignore via ignoreContactSender, per-row in-flight + inline error + optimistic-removal overlay) and outgoing pending; incoming avatars are never loaded (IP-leak avoidance). - AddContactScreen.kt: 300 ms-debounced DPNS search, paste-id (32-byte base58 gate), preview card, optional account label, collision dialog (Accept vs Continue anyway), and scan-to-send via sendContactRequestFromQr. - DashPaySyncHelpers.kt: attachOrStartSync / kickDashPaySync. - Routes.kt + AppNavHost.kt: DashPay child routes + graph wiring. - Stub screens (ContactDetail / Profile / Ignored / Hidden) with fixed signatures + routes so the hub navigates end-to-end now; K3 slice C replaces their bodies. Structural deviation from iOS: the hub navigates to per-section routes rather than embedding a Contacts/Requests segmented control, and the cross-screen optimistic-sent overlay is dropped (Add Contact is its own route). Signing uses the manager's signer + mnemonic-resolver handles directly (the retired FriendsScreen's approach). Build gate: :app:compileDebugKotlin passes. Co-Authored-By: Claude Fable 5 --- .../example/navigation/AppNavHost.kt | 36 ++ .../example/navigation/Routes.kt | 26 + .../example/ui/dashpay/AddContactScreen.kt | 531 ++++++++++++++++++ .../example/ui/dashpay/ContactDetailScreen.kt | 23 + .../ui/dashpay/ContactRequestsScreen.kt | 359 ++++++++++++ .../example/ui/dashpay/ContactsScreen.kt | 291 ++++++++++ .../example/ui/dashpay/DashPayJson.kt | 179 ++++++ .../ui/dashpay/DashPayProfileScreen.kt | 20 + .../example/ui/dashpay/DashPayStub.kt | 57 ++ .../example/ui/dashpay/DashPaySyncHelpers.kt | 34 ++ .../example/ui/dashpay/DashPayTabScreen.kt | 356 +++++++++++- .../ui/dashpay/HiddenContactsScreen.kt | 20 + .../ui/dashpay/IgnoredContactsScreen.kt | 20 + 13 files changed, 1935 insertions(+), 17 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt index 49cd458271b..9cc43027679 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt @@ -29,7 +29,14 @@ import org.dashfoundation.example.ui.diagnostics.WalletMemoryExplorerScreen import org.dashfoundation.example.ui.identity.AddIdentityKeyScreen import org.dashfoundation.example.ui.identity.ContestDetailScreen import org.dashfoundation.example.ui.identity.CreateIdentityScreen +import org.dashfoundation.example.ui.dashpay.AddContactScreen +import org.dashfoundation.example.ui.dashpay.ContactDetailScreen +import org.dashfoundation.example.ui.dashpay.ContactRequestsScreen +import org.dashfoundation.example.ui.dashpay.ContactsScreen +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.identity.DpnsTestScreen import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen import org.dashfoundation.example.ui.identity.IdentityDetailScreen @@ -379,6 +386,35 @@ fun AppNavHost( composable { DashPayTabScreen(navController) } + composable { entry -> + ContactsScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + ContactRequestsScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + AddContactScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + val route = entry.toRoute() + ContactDetailScreen(route.identityIdHex, route.contactIdHex, navController) + } + + composable { entry -> + DashPayProfileScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + IgnoredContactsScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + HiddenContactsScreen(entry.toRoute().ownerIdentityIdHex, navController) + } + // ── Diagnostics graph ────────────────────────────────────────── composable { AddressQueriesScreen(navController) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt index 2829d7cb5fb..509343da4c0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt @@ -176,6 +176,32 @@ import kotlinx.serialization.Serializable /** Shielded activity timeline for a wallet (← `ShieldedActivityView.swift`). */ @Serializable data class ShieldedActivity(val walletIdHex: String) +// ── DashPay graph (hosted under the DashPay tab, ← the DashPay/ views) ── + +/** Established-contacts list for an identity (← `ContactsView.swift`). */ +@Serializable data class DashPayContacts(val identityIdHex: String) + +/** Incoming + outgoing contact requests (← `ContactRequestsView.swift`). */ +@Serializable data class DashPayRequests(val identityIdHex: String) + +/** Add-a-contact form (DPNS search / paste id / QR, ← `AddContactView.swift`). */ +@Serializable data class DashPayAddContact(val identityIdHex: String) + +/** One contact's detail + payments (← `ContactDetailView.swift`). */ +@Serializable data class DashPayContactDetail( + val identityIdHex: String, + val contactIdHex: String, +) + +/** Read-only own-profile sheet (← `DashPayProfileView.swift`). */ +@Serializable data class DashPayProfile(val identityIdHex: String) + +/** Ignored-senders list (← `IgnoredContactsView.swift`). */ +@Serializable data class DashPayIgnored(val identityIdHex: String) + +/** Hidden established-contacts list (← `HiddenContactsView.swift`). */ +@Serializable data class DashPayHidden(val ownerIdentityIdHex: String) + // ── Contracts graph ──────────────────────────────────────────────────── /** Fetch-a-contract screen (← `LocalDataContractsView.swift`). */ diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt new file mode 100644 index 00000000000..ccf9e290eae --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -0,0 +1,531 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.QrScanner +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +private enum class AddMode { DPNS, IDENTITY_ID } + +private sealed interface SearchState { + data object Idle : SearchState + data object Searching : SearchState + data object NotFound : SearchState + data class Found(val results: List) : SearchState +} + +/** + * Add-a-contact form — port of `AddContactView.swift`. Two entry modes: + * DPNS username (300 ms-debounced live prefix search) and a pasted base58 + * identity id (inline 32-byte validation). A resolved target renders a + * preview card that gates Send; sending a request the target already sent + * *you* surfaces a collision dialog (Accept vs Continue anyway). A QR entry + * scans a DIP-15 auto-accept code and sends the request it describes. + * + * Deviation from Swift: the DPNS hint recorded on success is written to the + * device-local meta store directly here (Swift funnels it through the tab's + * `onSent`); the cross-screen optimistic-send overlay is not reproduced. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddContactScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = remember { DashPayContactMetaStore(context) } + + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { + walletId?.let { manager?.wallet(forWalletId = it) } + } + + var mode by remember { mutableStateOf(AddMode.DPNS) } + var searchText by remember { mutableStateOf("") } + var searchState by remember { mutableStateOf(SearchState.Idle) } + var selectedResult by remember { mutableStateOf(null) } + var idText by remember { mutableStateOf("") } + var accountLabel by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var collisionRecipient by remember { mutableStateOf(null) } + var previewProfile by remember { mutableStateOf(null) } + + val parsedIdentityId = remember(idText) { Base58.decodeIdentifier(idText) } + val resolvedRecipient: ByteArray? = when (mode) { + AddMode.DPNS -> selectedResult?.identityId + AddMode.IDENTITY_ID -> parsedIdentityId + } + val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true + val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending + + // 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). + val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle + LaunchedEffect(savedStateHandle, wallet, manager) { + savedStateHandle + ?.getStateFlow(QrScanner.RESULT_KEY, null) + ?.collect { raw -> + if (raw == null) return@collect + savedStateHandle[QrScanner.RESULT_KEY] = null + val w = wallet ?: return@collect + val m = manager ?: return@collect + isSending = true + errorMessage = null + try { + w.dashpay.sendContactRequestFromQr( + senderIdentityId = idBytes, + uri = raw.trim(), + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ).close() + kickDashPaySync(scope, m) + navController.popBackStack() + } catch (e: Exception) { + errorMessage = "QR request failed: ${e.message ?: "unknown error"}" + } finally { + isSending = false + } + } + } + + // Debounced (~300 ms) DPNS prefix search. Re-keying on searchText cancels + // the prior lookup for free; min 2 chars. + LaunchedEffect(searchText) { + selectedResult = null + val trimmed = searchText.trim() + if (trimmed.length < 2) { + searchState = SearchState.Idle + return@LaunchedEffect + } + delay(300) + val w = wallet + if (w == null) { + searchState = SearchState.Idle + errorMessage = "No wallet available for this identity" + return@LaunchedEffect + } + searchState = SearchState.Searching + try { + val results = parseDpnsSearchResults(w.dashpay.searchDpnsNames(trimmed, 10)) + searchState = if (results.isEmpty()) SearchState.NotFound else SearchState.Found(results) + } catch (e: Exception) { + searchState = SearchState.Idle + errorMessage = "Search failed: ${e.message ?: "unknown error"}" + } + } + + // Cache-only profile for the preview card (local read; most unknown + // identities won't have one). + val recipientHex = resolvedRecipient?.toHex() + LaunchedEffect(recipientHex) { + val recipient = resolvedRecipient + val w = wallet + previewProfile = if (recipient != null && w != null) { + parseDashPayProfile( + w.dashpay.getContactProfile(idBytes, recipient) + ?: w.dashpay.getProfile(recipient), + ) + } else { + null + } + } + + fun send(recipient: ByteArray) { + val w = wallet ?: return + val m = manager ?: return + isSending = true + errorMessage = null + scope.launch { + try { + val label = accountLabel.trim().ifEmpty { null } + w.dashpay.sendContactRequest( + senderIdentityId = idBytes, + recipientIdentityId = recipient, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + accountLabel = label, + ).close() + if (mode == AddMode.DPNS) { + selectedResult?.label?.let { + metaStore.setDpnsHint(it, network, idBytes, recipient) + } + } + kickDashPaySync(scope, m) + navController.popBackStack() + } catch (e: Exception) { + errorMessage = e.message ?: "Send failed" + } finally { + isSending = false + } + } + } + + fun acceptIncoming(recipient: ByteArray) { + val w = wallet ?: return + val m = manager ?: return + isSending = true + errorMessage = null + scope.launch { + try { + val ok = w.dashpay.acceptIncomingRequest( + ourIdentityId = idBytes, + senderId = recipient, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + if (ok) { + kickDashPaySync(scope, m) + navController.popBackStack() + } else { + errorMessage = "Their request isn't in local state — pull to refresh and " + + "accept it from Requests." + } + } catch (e: Exception) { + errorMessage = "Accept failed: ${e.message ?: "unknown error"}" + } finally { + isSending = false + } + } + } + + fun attemptSend() { + val recipient = resolvedRecipient ?: return + errorMessage = null + scope.launch { + val pendingIncoming = runCatching { + container.database.dashpayDao().getContactRequestsByOwner(idBytes) + .filter { it.contactIdentityId.contentEquals(recipient) } + .let { pair -> pair.any { !it.isOutgoing } && pair.none { it.isOutgoing } } + }.getOrDefault(false) + if (pendingIncoming) { + collisionRecipient = recipient + } else { + send(recipient) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Contact") }, + navigationIcon = { + TextButton( + onClick = { navController.popBackStack() }, + enabled = !isSending, + modifier = Modifier.testTag("dashpay.addContact.cancel"), + ) { Text("Cancel") } + }, + actions = { + IconButton( + onClick = { navController.navigate(QrScanner) }, + enabled = !isSending, + modifier = Modifier.testTag("dashpay.addViaQR"), + ) { + Icon(Icons.Default.QrCodeScanner, contentDescription = "Scan QR") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + org.dashfoundation.example.ui.components.AccessiblePicker( + label = "Search by", + options = AddMode.entries, + selected = mode, + optionLabel = { if (it == AddMode.DPNS) "Username (DPNS)" else "Identity ID" }, + testTag = "dashpay.addContact.mode", + ) { mode = it } + + when (mode) { + AddMode.DPNS -> DpnsSection( + searchText = searchText, + onSearchTextChange = { searchText = it }, + onClear = { searchText = "" }, + state = searchState, + selectedResult = selectedResult, + onSelect = { selectedResult = it; errorMessage = null }, + ) + AddMode.IDENTITY_ID -> IdSection( + idText = idText, + onIdTextChange = { idText = it }, + isInvalid = idText.trim().isNotEmpty() && parsedIdentityId == null, + ) + } + + val recipient = resolvedRecipient + if (recipient != null) { + PreviewSection( + recipient = recipient, + profile = previewProfile, + dpnsLabel = selectedResult?.label, + isSelf = recipientIsSelf, + ) + FormSection(title = "Account label (optional)") { + OutlinedTextField( + value = accountLabel, + onValueChange = { accountLabel = it }, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.accountLabel"), + placeholder = { Text("e.g. Main wallet") }, + singleLine = true, + ) + } + SubmitButton( + text = "Send Request", + isLoading = isSending, + enabled = canSend, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), + ) { attemptSend() } + } + + if (errorMessage != null) { + Text( + errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + val collision = collisionRecipient + if (collision != null) { + AlertDialog( + onDismissRequest = { collisionRecipient = null }, + title = { Text("Request already received") }, + text = { Text("This person already sent you a request — accept it instead?") }, + confirmButton = { + TextButton(onClick = { + collisionRecipient = null + acceptIncoming(collision) + }) { Text("Accept") } + }, + dismissButton = { + TextButton(onClick = { + collisionRecipient = null + send(collision) + }) { Text("Continue anyway") } + }, + ) + } +} + +@Composable +private fun DpnsSection( + searchText: String, + onSearchTextChange: (String) -> Unit, + onClear: () -> Unit, + state: SearchState, + selectedResult: DpnsSearchResult?, + onSelect: (DpnsSearchResult) -> Unit, +) { + FormSection(title = "Username") { + OutlinedTextField( + value = searchText, + onValueChange = onSearchTextChange, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.input"), + placeholder = { Text("Search usernames") }, + singleLine = true, + trailingIcon = { + if (searchText.isNotEmpty()) { + IconButton(onClick = onClear, modifier = Modifier.testTag("dashpay.addContact.clear")) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + ) + when (state) { + SearchState.Idle -> { + if (searchText.trim().length < 2) { + HintText("Type at least 2 characters to search.") + } + } + SearchState.Searching -> { + Row( + modifier = Modifier.padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + HintText("Searching…") + } + } + SearchState.NotFound -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HintText("No usernames match \"${searchText.trim()}\".") + TextButton( + onClick = onClear, + modifier = Modifier.testTag("dashpay.addContact.retry"), + ) { Text("Clear and try again") } + } + } + is SearchState.Found -> { + state.results.forEach { result -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(result) } + .testTag("dashpay.addContact.result.${result.label}") + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(avatarUrl = null, displayName = result.label, size = 32.dp) + Column(Modifier.weight(1f)) { + Text(result.label, style = MaterialTheme.typography.bodyLarge) + Text( + Base58.encode(result.identityId).take(16) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (selectedResult == result) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} + +@Composable +private fun IdSection(idText: String, onIdTextChange: (String) -> Unit, isInvalid: Boolean) { + FormSection(title = "Identity ID") { + OutlinedTextField( + value = idText, + onValueChange = onIdTextChange, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.idInput"), + placeholder = { Text("Paste identity ID (base58)") }, + singleLine = true, + ) + if (isInvalid) { + Text( + "Not a valid identity id (expected base58)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun PreviewSection( + recipient: ByteArray, + profile: DashPayProfile?, + dpnsLabel: String?, + isSelf: Boolean, +) { + val name = dashPayContactDisplayName( + contactId = recipient, + alias = null, + profileDisplayName = profile?.displayName, + dpnsLabel = dpnsLabel, + ) + FormSection(title = "Send to") { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(profile?.avatarUrl, name) + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.titleMedium) + Text( + Base58.encode(recipient).take(20) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val message = profile?.publicMessage?.trim() + if (!message.isNullOrEmpty()) { + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } + } + if (isSelf) { + Text( + "That's this identity — pick someone else.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun HintText(text: String) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt new file mode 100644 index 00000000000..76200eec8c4 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt @@ -0,0 +1,23 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.navigation.NavHostController + +/** + * One contact's detail + payments — placeholder for the port of + * `ContactDetailView.swift`. Replaced by the K3 slice C implementation + * (header / Send Dash / payment history / local alias-note-hide settings); + * the signature and route ([org.dashfoundation.example.navigation.DashPayContactDetail]) + * are fixed here so ContactsScreen's row navigation compiles now. + */ +@androidx.compose.runtime.Composable +fun ContactDetailScreen( + identityIdHex: String, + contactIdHex: String, + navController: NavHostController, +) { + DashPayStubScaffold( + title = "Contact", + rootTestTag = "dashpay.detail.root", + navController = navController, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt new file mode 100644 index 00000000000..ba1f2b97434 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -0,0 +1,359 @@ +package org.dashfoundation.example.ui.dashpay + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.ui.components.SectionHeader +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * Incoming + outgoing contact requests — port of `ContactRequestsView.swift`. + * Incoming rows carry Accept / Ignore with per-row in-flight state + inline + * error; the Outgoing section shows pending sent requests. Rows are derived + * from the Room contact-request rows: a `(owner, contact)` pair with only an + * incoming row is a pending incoming request, only an outgoing row a pending + * sent one, and both directions an established contact (shown in Contacts). + * + * Deviation from Swift: the cross-screen optimistic *sent* overlay is dropped + * (AddContact is a separate route here, not a child sharing a `@Binding`); a + * sent request appears once the post-send sync persists its outgoing row. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactRequestsScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = remember { DashPayContactMetaStore(context) } + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { + walletId?.let { manager?.wallet(forWalletId = it) } + } + + val rows by remember(idBytes) { + container.database.dashpayDao().observeContactRequests(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(idBytes) { + container.database.dashpayDao().observeContactProfiles(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val profilesByHex = remember(contactProfiles) { + contactProfiles.associateBy { it.contactIdentityId.toHex() } + } + + var inFlightIds by remember { mutableStateOf(emptySet()) } + var removedOverlayIds by remember { mutableStateOf(emptySet()) } + var rowErrors by remember { mutableStateOf(emptyMap()) } + var isRefreshing by remember { mutableStateOf(false) } + + val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) + .collectAsStateWithLifecycle(false) + + // Prune the optimistic-removal overlay once the query reflects the change + // (accept promotes to established, ignore deletes the row). + LaunchedEffect(rows) { + val byContact = rows.groupBy { it.contactIdentityId.toHex() } + removedOverlayIds = removedOverlayIds.filter { hex -> + val group = byContact[hex] ?: return@filter false + group.any { !it.isOutgoing } && group.none { it.isOutgoing } + }.toSet() + } + // Fallback clear: after the next completed sweep, expire whatever the + // query still hasn't reflected so no row stays hidden forever. + LaunchedEffect(isSyncing) { + if (!isSyncing) removedOverlayIds = emptySet() + } + + fun displayNameFor(contactId: ByteArray): String = dashPayContactDisplayName( + contactId = contactId, + alias = metaStore.alias(network, idBytes, contactId), + profileDisplayName = profilesByHex[contactId.toHex()]?.displayName, + dpnsLabel = metaStore.dpnsHint(network, idBytes, contactId), + ) + + val incomingPending = remember(rows, removedOverlayIds, profilesByHex, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + if (removedOverlayIds.contains(hex)) return@mapNotNull null + if (group.any { it.isOutgoing }) return@mapNotNull null + val incoming = group.firstOrNull { !it.isOutgoing } ?: return@mapNotNull null + RequestRowItem( + contactId = incoming.contactIdentityId, + displayName = displayNameFor(incoming.contactIdentityId), + // Privacy: never load an unsolicited sender's avatar before + // the user accepts (an image GET leaks the recipient's IP). + avatarUrl = null, + createdAtMillis = incoming.createdAtMillis, + ) + } + .sortedByDescending { it.createdAtMillis } + } + + val outgoingPending = remember(rows, profilesByHex, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + if (group.any { !it.isOutgoing }) return@mapNotNull null + val outgoing = group.firstOrNull { it.isOutgoing } ?: return@mapNotNull null + RequestRowItem( + contactId = outgoing.contactIdentityId, + displayName = displayNameFor(outgoing.contactIdentityId), + avatarUrl = profilesByHex[hex]?.avatarUrl, + createdAtMillis = outgoing.createdAtMillis, + ) + } + .sortedByDescending { it.createdAtMillis } + } + + fun accept(contactId: ByteArray) { + val w = wallet ?: return + val m = manager ?: return + val hex = contactId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + val ok = w.dashpay.acceptIncomingRequest( + ourIdentityId = idBytes, + senderId = contactId, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + if (ok) { + removedOverlayIds = removedOverlayIds + hex + kickDashPaySync(scope, m) + } else { + rowErrors = rowErrors + (hex to "Request not in local state — pull to refresh") + } + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Accept failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + fun ignore(contactId: ByteArray) { + val w = wallet ?: return + val hex = contactId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + w.dashpay.ignoreContactSender(ourIdentityId = idBytes, contactIdentityId = contactId) + removedOverlayIds = removedOverlayIds + hex + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Ignore failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Requests") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + manager?.let { attachOrStartSync(it) } + isRefreshing = false + } + }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (incomingPending.isEmpty() && outgoingPending.isEmpty()) { + item { + DashPayEmptyRow( + title = "No pending requests", + message = "Incoming contact requests and your pending sent requests " + + "show up here.", + ) + } + } else { + if (incomingPending.isNotEmpty()) { + item { SectionHeader("Incoming (${incomingPending.size})") } + items(incomingPending, key = { "in:${it.contactId.toHex()}" }) { row -> + val hex = row.contactId.toHex() + IncomingRequestRow( + item = row, + isInFlight = inFlightIds.contains(hex), + errorMessage = rowErrors[hex], + onAccept = { accept(row.contactId) }, + onIgnore = { ignore(row.contactId) }, + ) + } + } + if (outgoingPending.isNotEmpty()) { + item { SectionHeader("Outgoing (${outgoingPending.size})") } + items(outgoingPending, key = { "out:${it.contactId.toHex()}" }) { row -> + OutgoingRequestRow(row) + } + } + } + } + } + } +} + +/** UI model for one request row (incoming or outgoing). */ +private data class RequestRowItem( + val contactId: ByteArray, + val displayName: String, + val avatarUrl: String?, + val createdAtMillis: Long, +) + +@Composable +private fun IncomingRequestRow( + item: RequestRowItem, + isInFlight: Boolean, + errorMessage: String?, + onAccept: () -> Unit, + onIgnore: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(item.avatarUrl, item.displayName) + Column(Modifier.weight(1f)) { + Text(item.displayName, style = MaterialTheme.typography.titleMedium) + Text( + relativeTimestamp(item.createdAtMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isInFlight) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onAccept, modifier = Modifier.testTag("dashpay.request.accept")) { + Text("Accept") + } + OutlinedButton( + onClick = onIgnore, + modifier = Modifier.testTag("dashpay.request.ignore"), + ) { + Text("Ignore") + } + } + } + if (errorMessage != null) { + Text( + errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun OutgoingRequestRow(item: RequestRowItem) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(item.avatarUrl, item.displayName) + Column(Modifier.weight(1f)) { + Text(item.displayName, style = MaterialTheme.typography.titleMedium) + Text( + relativeTimestamp(item.createdAtMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + "Pending", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.tertiary, + ) + } +} + +/** "3 min. ago"-style relative time from Unix millis; "—" for the zero sentinel. */ +private fun relativeTimestamp(millis: Long): String { + if (millis <= 0) return "—" + return DateUtils.getRelativeTimeSpanString( + millis, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt new file mode 100644 index 00000000000..48138fc354a --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt @@ -0,0 +1,291 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.DashPayContactDetail +import org.dashfoundation.example.navigation.DashPayHidden +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * Established-contacts list — port of `ContactsView.swift`. A contact is + * *established* when both direction rows exist for the same + * `(owner, contact)` pair (the local projection of the Rust `established` + * map); hidden contacts stay established but leave this list. Rows read + * cached names/avatars from Room + * ([org.dashfoundation.dashsdk.persistence.dao.DashpayDao.observeContactProfiles]) + * joined with the device-local alias/DPNS-hint meta store, searchable, with + * a "Hidden contacts" recovery link and pull-to-refresh. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactsScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = remember { DashPayContactMetaStore(context) } + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val rows by remember(idBytes) { + container.database.dashpayDao().observeContactRequests(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(idBytes) { + container.database.dashpayDao().observeContactProfiles(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + + var searchText by remember { mutableStateOf("") } + var isRefreshing by remember { mutableStateOf(false) } + + val profilesByHex = remember(contactProfiles) { + contactProfiles.associateBy { it.contactIdentityId.toHex() } + } + + val established = remember(rows, profilesByHex, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + val hasOutgoing = group.any { it.isOutgoing } + val hasIncoming = group.any { !it.isOutgoing } + if (!hasOutgoing || !hasIncoming) return@mapNotNull null + if (group.any { it.contactHidden }) return@mapNotNull null + val contactId = group.first().contactIdentityId + val profile = profilesByHex[hex] + val dpnsHint = metaStore.dpnsHint(network, idBytes, contactId) + EstablishedContact( + contactId = contactId, + displayName = dashPayContactDisplayName( + contactId = contactId, + alias = group.firstNotNullOfOrNull { it.contactAlias }, + profileDisplayName = profile?.displayName, + dpnsLabel = dpnsHint, + ), + avatarUrl = profile?.avatarUrl, + dpnsName = dpnsHint, + paymentChannelBroken = group.any { it.paymentChannelBroken }, + ) + } + .sortedBy { it.displayName.lowercase() } + } + + val hasHiddenContacts = remember(rows) { + rows.groupBy { it.contactIdentityId.toHex() }.any { (_, group) -> + group.any { it.isOutgoing } && group.any { !it.isOutgoing } && + group.any { it.contactHidden } + } + } + + val filtered = remember(established, searchText) { + val trimmed = searchText.trim() + if (trimmed.isEmpty()) { + established + } else { + established.filter { contact -> + contact.displayName.contains(trimmed, ignoreCase = true) || + contact.dpnsName?.contains(trimmed, ignoreCase = true) == true || + contact.contactId.toHex().startsWith(trimmed.lowercase()) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Contacts") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + manager?.let { attachOrStartSync(it) } + isRefreshing = false + } + }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (filtered.isEmpty() && searchText.isEmpty() && !hasHiddenContacts) { + item { + DashPayEmptyRow( + title = "No contacts yet", + message = "Add your first contact to send Dash by username.", + ) + } + } else { + item { + SearchField( + value = searchText, + onValueChange = { searchText = it }, + onClear = { searchText = "" }, + ) + } + items(filtered, key = { it.contactId.toHex() }) { contact -> + ContactRow( + contact = contact, + onClick = { + navController.navigate( + DashPayContactDetail( + identityIdHex = identityIdHex, + contactIdHex = contact.contactId.toHex(), + ), + ) + }, + ) + } + if (hasHiddenContacts) { + item { + ListItem( + headlineContent = { Text("Hidden contacts") }, + modifier = Modifier + .fillMaxWidth() + .clickable { navController.navigate(DashPayHidden(idBytes.toHex())) } + .testTag("dashpay.openHidden"), + ) + } + } + } + } + } + } +} + +/** UI model for one established contact row. */ +data class EstablishedContact( + val contactId: ByteArray, + val displayName: String, + val avatarUrl: String?, + val dpnsName: String?, + val paymentChannelBroken: Boolean, +) { + override fun equals(other: Any?): Boolean = + other is EstablishedContact && contactId.contentEquals(other.contactId) + + override fun hashCode(): Int = contactId.contentHashCode() +} + +@Composable +private fun ContactRow(contact: EstablishedContact, onClick: () -> Unit) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .testTag("dashpay.contact.${Base58.encode(contact.contactId)}"), + leadingContent = { DashPayAvatar(contact.avatarUrl, contact.displayName) }, + headlineContent = { Text(contact.displayName, style = MaterialTheme.typography.titleMedium) }, + supportingContent = { + Text( + contact.dpnsName ?: (contact.contactId.toHex().take(12) + "…"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = if (contact.paymentChannelBroken) { + { + Icon( + Icons.Default.Warning, + contentDescription = "Payment channel broken", + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + null + }, + ) +} + +/** Shared inline search row (← Swift `searchField`). */ +@Composable +internal fun SearchField(value: String, onValueChange: (String) -> Unit, onClear: () -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth().testTag("dashpay.search"), + placeholder = { Text("Search contacts") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (value.isNotEmpty()) { + IconButton(onClick = onClear, modifier = Modifier.testTag("dashpay.search.clear")) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + }, + ) +} + +/** + * Inline empty state — the shared "list empty" row (← Swift + * `DashPayListEmptyRow`), kept inside the scrollable so pull-to-refresh + * still works on an empty list. + */ +@Composable +internal fun DashPayEmptyRow(title: String, message: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt new file mode 100644 index 00000000000..cab86124046 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt @@ -0,0 +1,179 @@ +package org.dashfoundation.example.ui.dashpay + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Plain data classes + org.json parsers for the JSON-string reads on the + * DashPay FFI surface (`Dashpay.getProfile` / `getContactProfile`, + * `searchDpnsNames`, `PlatformWalletManager.accountBalances`, and + * `Dashpay.payments`). Field shapes are documented on + * `org.dashfoundation.dashsdk.ffi.DashpayNative`; parsing happens here, + * Kotlin-side, following the SDK's JSON-string-read precedent. + * + * Parsers are total and lenient: a null/blank/unparseable input yields null + * (or an empty list), and per-row failures are skipped rather than thrown, + * so a single malformed row never blanks the whole list. + */ + +// ── Profile (getProfile / getContactProfile) ───────────────────────────── + +/** Cached DashPay profile — the public fields the requests/contacts UI renders. */ +data class DashPayProfile( + val displayName: String?, + val publicMessage: String?, + val avatarUrl: String?, +) + +/** Parse a `getProfile` / `getContactProfile` JSON object, or null. */ +fun parseDashPayProfile(json: String?): DashPayProfile? { + val obj = json?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null + return DashPayProfile( + displayName = obj.optStringOrNull("displayName"), + publicMessage = obj.optStringOrNull("publicMessage"), + avatarUrl = obj.optStringOrNull("avatarUrl"), + ) +} + +// ── DPNS search (searchDpnsNames) ──────────────────────────────────────── + +/** One DPNS prefix-search hit: the resolved label + its 32-byte identity id. */ +data class DpnsSearchResult( + /** The DPNS label (Swift `fullName`) — also the row's testTag suffix. */ + val label: String, + val identityId: ByteArray, +) { + override fun equals(other: Any?): Boolean = + other is DpnsSearchResult && label == other.label && + identityId.contentEquals(other.identityId) + + override fun hashCode(): Int = 31 * label.hashCode() + identityId.contentHashCode() +} + +/** Parse a `searchDpnsNames` JSON array of `{"label":…,"identityId":…hex}`. */ +fun parseDpnsSearchResults(json: String?): List { + val array = json?.let { runCatching { JSONArray(it) }.getOrNull() } ?: return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + val label = row.optStringOrNull("label") ?: continue + val id = row.optStringOrNull("identityId")?.hexOrNull()?.takeIf { it.size == 32 } ?: continue + out.add(DpnsSearchResult(label, id)) + } + return out +} + +// ── Account balances (PlatformWalletManager.accountBalances) ────────────── + +/** + * One per-account balance row. The DashPay "received from contacts" total is + * the sum of `confirmed + unconfirmed` over rows with [typeTag] == 12 + * (`DashpayReceivingFunds`) whose [userIdentityId] matches the identity. + */ +data class AccountBalance( + val typeTag: Int, + val userIdentityId: ByteArray, + val friendIdentityId: ByteArray, + val confirmed: Long, + val unconfirmed: Long, +) { + override fun equals(other: Any?): Boolean = + other is AccountBalance && typeTag == other.typeTag && + userIdentityId.contentEquals(other.userIdentityId) && + friendIdentityId.contentEquals(other.friendIdentityId) && + confirmed == other.confirmed && unconfirmed == other.unconfirmed + + override fun hashCode(): Int { + var result = typeTag + result = 31 * result + userIdentityId.contentHashCode() + result = 31 * result + friendIdentityId.contentHashCode() + result = 31 * result + confirmed.hashCode() + result = 31 * result + unconfirmed.hashCode() + return result + } +} + +/** Parse a `accountBalances` JSON array, or an empty list. */ +fun parseAccountBalances(json: String?): List { + val array = json?.let { runCatching { JSONArray(it) }.getOrNull() } ?: return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + out.add( + AccountBalance( + typeTag = row.optInt("typeTag"), + userIdentityId = row.optStringOrNull("userIdentityId")?.hexOrNull() ?: ByteArray(0), + friendIdentityId = row.optStringOrNull("friendIdentityId")?.hexOrNull() + ?: ByteArray(0), + confirmed = row.optLong("confirmed"), + unconfirmed = row.optLong("unconfirmed"), + ), + ) + } + return out +} + +// ── Payment history (Dashpay.payments) ─────────────────────────────────── + +/** One DashPay payment. [direction] 0 Sent / 1 Received; [status] 0 Pending / 1 Confirmed / 2 Failed. */ +data class DashPayPayment( + val txid: String, + val counterpartyId: ByteArray, + val amountDuffs: Long, + val direction: Int, + val status: Int, + val memo: String?, +) { + override fun equals(other: Any?): Boolean = + other is DashPayPayment && txid == other.txid && + counterpartyId.contentEquals(other.counterpartyId) && + amountDuffs == other.amountDuffs && direction == other.direction && + status == other.status && memo == other.memo + + override fun hashCode(): Int { + var result = txid.hashCode() + result = 31 * result + counterpartyId.contentHashCode() + result = 31 * result + amountDuffs.hashCode() + result = 31 * result + direction + result = 31 * result + status + result = 31 * result + (memo?.hashCode() ?: 0) + return result + } +} + +/** Parse a `payments` JSON array (rows missing txid/counterparty are skipped). */ +fun parseDashPayPayments(json: String?): List { + val array = json?.let { runCatching { JSONArray(it) }.getOrNull() } ?: return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + val txid = row.optStringOrNull("txid") ?: continue + val counterparty = row.optStringOrNull("counterpartyId")?.hexOrNull() + ?.takeIf { it.size == 32 } ?: continue + out.add( + DashPayPayment( + txid = txid, + counterpartyId = counterparty, + amountDuffs = row.optLong("amountDuffs"), + direction = row.optInt("direction"), + status = row.optInt("status"), + memo = row.optStringOrNull("memo"), + ), + ) + } + return out +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** `optString` but null (not the JSON literal `"null"` / empty) when absent. */ +private fun JSONObject.optStringOrNull(key: String): String? = + if (isNull(key)) null else optString(key, "").takeIf { it.isNotEmpty() } + +/** Lower/upper-hex → bytes; null on odd length or a non-hex digit. */ +private fun String.hexOrNull(): ByteArray? { + if (length % 2 != 0) return null + return runCatching { + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + }.getOrNull() +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt new file mode 100644 index 00000000000..784c07cd6af --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -0,0 +1,20 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +/** + * Read-only own-profile view + DIP-15 auto-accept QR — placeholder for the + * port of `DashPayProfileView.swift`. Replaced by the K3 slice C + * implementation; the signature + route + * ([org.dashfoundation.example.navigation.DashPayProfile]) are fixed here so + * the hub's "Your Profile" entry compiles now. + */ +@Composable +fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController) { + DashPayStubScaffold( + title = "Your Profile", + rootTestTag = "dashpay.profile.root", + navController = navController, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt new file mode 100644 index 00000000000..3e294f79576 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavHostController + +/** + * Shared placeholder scaffold for the DashPay screens still owned by K3 + * slice C (ContactDetail / Profile / Ignored / Hidden). Gives each a titled + * bar + back button + a root testTag so navigation from the hub / Contacts + * works end-to-end before those screens land. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DashPayStubScaffold( + title: String, + rootTestTag: String, + navController: NavHostController, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding).testTag(rootTestTag), + contentAlignment = Alignment.Center, + ) { + Text( + "Coming in the next milestone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt new file mode 100644 index 00000000000..0080bbdb3a7 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -0,0 +1,34 @@ +package org.dashfoundation.example.ui.dashpay + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.PlatformWalletManager + +/** + * Shared DashPay sync helpers — port of the free functions in + * `ContactsView.swift` (`attachOrStartSync` / `kickDashPaySync`), used by + * the DashPay hub, Contacts, and Requests screens. + */ + +/** + * Pull-to-refresh sync: if a sweep is already in flight, attach to it (wait + * for [PlatformWalletManager.dashPaySyncIsSyncing] to clear) instead of + * double-firing; otherwise run one pass. ← Swift `attachOrStartSync`. + */ +suspend fun attachOrStartSync(manager: PlatformWalletManager) { + if (manager.dashPaySyncIsSyncing.value) { + manager.dashPaySyncIsSyncing.first { !it } + } else { + runCatching { manager.dashPaySyncNow() } + } +} + +/** + * Fire-and-forget kick of a sweep pass after a local mutation (send / + * accept / pay) so the user isn't left on a stale list. The Rust manager + * folds an in-flight pass into a no-op. ← Swift `kickDashPaySync`. + */ +fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { + scope.launch { runCatching { manager.dashPaySyncNow() } } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index b72950db9c9..5b9040fdd86 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -1,45 +1,367 @@ package org.dashfoundation.example.ui.dashpay -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.DashPayAddContact +import org.dashfoundation.example.navigation.DashPayContacts +import org.dashfoundation.example.navigation.DashPayHidden +import org.dashfoundation.example.navigation.DashPayIgnored +import org.dashfoundation.example.navigation.DashPayProfile +import org.dashfoundation.example.navigation.DashPayRequests +import org.dashfoundation.example.navigation.IdentitiesHome +import org.dashfoundation.example.navigation.WalletsHome +import org.dashfoundation.example.ui.components.AccessiblePicker +import org.dashfoundation.example.ui.components.EntityRow +import org.dashfoundation.example.ui.components.ErrorAlertDialog +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.formatDuffs +import org.dashfoundation.example.util.toHex /** - * DashPay tab root — placeholder for the port of `DashPayTabView.swift`. + * DashPay tab root — port of `DashPayTabView.swift`. Picks the active + * wallet-backed identity, surfaces the received-from-contacts balance and the + * seedless-unlock banner, and hosts the DashPay sections (Contacts, Requests, + * Add Contact, Your Profile, Ignored, Hidden). Pull-to-refresh and the + * toolbar refresh both drive one `dashPaySyncNow()` sweep. * - * The DASHPAY tab is first-class as of this milestone; the contacts / - * requests / profile hub replaces this empty state in the next slice. The - * [navController] is accepted now so the hub's child navigation attaches - * without a route re-registration. + * Structural note: iOS embeds Contacts/Requests as a segmented control inside + * this view; the Kotlin hub instead navigates to per-section routes (a + * cleaner Compose fit), so `dashpay.segment` / `dashpay.profileHeader` / + * `dashpay.usernamePrompt` are not reproduced here. + * + * Deviation: the active-identity selection is in-memory (`remember(network)`), + * so it resets on a network switch (matching iOS) but is not persisted across + * launches (iOS uses `@AppStorage`). */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashPayTabScreen(navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val walletsMap by remember(manager) { + manager?.wallets ?: MutableStateFlow(emptyMap()) + }.collectAsStateWithLifecycle() + + val allWalletOwned by remember(network) { + container.database.identityDao().observeWalletOwnedByNetwork(network.ffiValue) + }.collectAsStateWithLifecycle(emptyList()) + + // Eligible = wallet-backed identities whose wallet is loaded (← Swift + // eligibleIdentities). Sorted by creation for a stable picker order. + val eligible = remember(allWalletOwned, walletsMap) { + allWalletOwned + .filter { it.walletId != null && walletsMap.containsKey(it.walletId!!.toHex()) } + .sortedBy { it.createdAt.time } + } + + var selectedIdBase58 by remember(network) { mutableStateOf(null) } + val activeIdentity = remember(eligible, selectedIdBase58) { + eligible.firstOrNull { Base58.encode(it.identityId) == selectedIdBase58 } ?: eligible.firstOrNull() + } + + var isRefreshing by remember { mutableStateOf(false) } + var unlockError by remember { mutableStateOf(null) } + + fun refresh() { + scope.launch { + isRefreshing = true + manager?.let { runCatching { it.dashPaySyncNow() } } + isRefreshing = false + } + } + Scaffold( - topBar = { TopAppBar(title = { Text("DashPay") }) }, + topBar = { + TopAppBar( + title = { Text("DashPay") }, + actions = { + IconButton(onClick = { refresh() }, modifier = Modifier.testTag("dashpay.refresh")) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + ) + }, ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .testTag("dashpay.tab.root"), - contentAlignment = Alignment.Center, + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { refresh() }, + modifier = Modifier.fillMaxSize().padding(padding), ) { - Text( - "DashPay contacts arrive in the next milestone.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag("dashpay.tab"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when { + walletsMap.isEmpty() -> EmptyState( + title = "No wallet loaded", + message = "Load or create a wallet to use DashPay.", + buttonTitle = "Open Wallets", + buttonTestTag = "dashpay.openWallets", + onClick = { navController.navigate(WalletsHome) }, + ) + eligible.isEmpty() || activeIdentity == null -> EmptyState( + title = "No identities yet", + message = "Register an identity to start using DashPay.", + buttonTitle = "Open Identities", + buttonTestTag = "dashpay.openIdentities", + onClick = { navController.navigate(IdentitiesHome) }, + ) + else -> { + val identity = activeIdentity + val identityHex = identity.identityId.toHex() + val managed = identity.walletId?.let { manager?.wallet(forWalletId = it) } + + if (eligible.size > 1) { + AccessiblePicker( + label = "Identity", + options = eligible, + selected = identity, + optionLabel = { pickerLabel(it.identityId, it.mainDpnsName ?: it.dpnsName) }, + testTag = "dashpay.identityPicker", + ) { selectedIdBase58 = Base58.encode(it.identityId) } + } + + BalanceRow( + manager = manager, + walletId = identity.walletId, + identityId = identity.identityId, + ) + + UnlockBanner( + manager = manager, + walletIdHex = identity.walletId?.toHex(), + managed = managed, + onError = { unlockError = it }, + ) + + FormSection(title = "DashPay") { + EntityRow( + icon = Icons.Default.Group, + title = "Contacts", + onClick = { navController.navigate(DashPayContacts(identityHex)) }, + modifier = Modifier.testTag("dashpay.openContacts"), + ) + EntityRow( + icon = Icons.Default.Inbox, + title = "Requests", + onClick = { navController.navigate(DashPayRequests(identityHex)) }, + modifier = Modifier.testTag("dashpay.openRequests"), + ) + EntityRow( + icon = Icons.Default.PersonAdd, + title = "Add Contact", + onClick = { navController.navigate(DashPayAddContact(identityHex)) }, + modifier = Modifier.testTag("dashpay.addContact"), + ) + EntityRow( + icon = Icons.Default.Person, + title = "Your Profile", + onClick = { navController.navigate(DashPayProfile(identityHex)) }, + modifier = Modifier.testTag("dashpay.openProfile"), + ) + EntityRow( + icon = Icons.Default.Block, + title = "Ignored", + onClick = { navController.navigate(DashPayIgnored(identityHex)) }, + modifier = Modifier.testTag("dashpay.openIgnored"), + ) + EntityRow( + icon = Icons.Default.VisibilityOff, + title = "Hidden", + onClick = { navController.navigate(DashPayHidden(identityHex)) }, + modifier = Modifier.testTag("dashpay.openHidden"), + ) + } + } + } + } + } + } + + ErrorAlertDialog(message = unlockError, onDismiss = { unlockError = null }) +} + +@Composable +private fun BalanceRow( + manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager?, + walletId: ByteArray?, + identityId: ByteArray, +) { + var receivedDuffs by remember(walletId, identityId) { mutableStateOf(0L) } + // Re-read after each completed sweep so received funds appear without a + // screen reopen (the balance is an in-memory Rust snapshot pull-refresh + // advances). + val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) + .collectAsStateWithLifecycle(false) + LaunchedEffect(manager, walletId, identityId, isSyncing) { + val m = manager + receivedDuffs = if (m != null && walletId != null) { + parseAccountBalances(m.accountBalances(walletId)) + .filter { it.typeTag == 12 && it.userIdentityId.contentEquals(identityId) } + .sumOf { it.confirmed + it.unconfirmed } + } else { + 0L + } + } + FormSection { + LabeledContent( + label = "Received from contacts", + value = formatDuffs(receivedDuffs), + modifier = Modifier.testTag("dashpay.receivedBalance"), + ) + } +} + +@Composable +private fun UnlockBanner( + manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager?, + walletIdHex: String?, + managed: ManagedPlatformWallet?, + onError: (String) -> Unit, +) { + val m = manager ?: return + val statusMap by m.dashPayUnlockStatus.collectAsStateWithLifecycle() + val status = walletIdHex?.let { statusMap[it] } ?: return + val hasSignal = status.draining || status.seedMismatch || status.pendingAccountBuilds > 0 + if (!hasSignal) return + val scope = rememberCoroutineScope() + + when { + status.seedMismatch -> BannerRow( + icon = Icons.Default.Warning, + tint = MaterialTheme.colorScheme.error, + text = "Seed verification failed — this wallet's Keystore seed doesn't match. " + + "DashPay signing is disabled.", + action = null, + ) + status.draining -> BannerRow( + icon = Icons.Default.Lock, + tint = MaterialTheme.colorScheme.tertiary, + text = "Finishing contact setup…", + action = null, + ) + status.pendingAccountBuilds > 0 -> { + val n = status.pendingAccountBuilds + BannerRow( + icon = Icons.Default.Lock, + tint = MaterialTheme.colorScheme.tertiary, + text = "$n contact${if (n == 1) "" else "s"} waiting to finish setup", + action = if (managed != null) { + { + scope.launch { + try { + val unlocked = m.unlockWalletFromKeystore(managed) + if (!unlocked) { + onError( + "This wallet is watch-only on this device (no mnemonic in " + + "the Keystore), so contact setup can't be finished here.", + ) + } + } catch (e: Exception) { + onError(e.message ?: "Unlock failed") + } + } + } + } else { + null + }, ) } } } + +@Composable +private fun BannerRow(icon: ImageVector, tint: androidx.compose.ui.graphics.Color, text: String, action: (() -> Unit)?) { + Row( + modifier = Modifier.fillMaxWidth().testTag("dashpay.unlockBanner"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = tint) + Text(text, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + if (action != null) { + Button(onClick = action) { Text("Unlock") } + } + } +} + +@Composable +private fun EmptyState( + title: String, + message: String, + buttonTitle: String, + buttonTestTag: String, + onClick: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onClick, modifier = Modifier.testTag(buttonTestTag)) { Text(buttonTitle) } + } +} + +/** Picker label: DPNS name when known, else a truncated base58 id. */ +private fun pickerLabel(identityId: ByteArray, dpnsName: String?): String = + dpnsName?.takeIf { it.isNotBlank() } ?: (Base58.encode(identityId).take(12) + "…") diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt new file mode 100644 index 00000000000..64f40bf97cf --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt @@ -0,0 +1,20 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +/** + * Hidden established-contacts list (reversible, cross-device) — placeholder + * for the port of `HiddenContactsView.swift`. Replaced by the K3 slice C + * implementation; the signature + route + * ([org.dashfoundation.example.navigation.DashPayHidden]) are fixed here so + * ContactsScreen's "Hidden contacts" link and the hub entry compile now. + */ +@Composable +fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostController) { + DashPayStubScaffold( + title = "Hidden", + rootTestTag = "dashpay.hidden.root", + navController = navController, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt new file mode 100644 index 00000000000..3018463a63d --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt @@ -0,0 +1,20 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +/** + * Ignored-senders list (per-sender mute, reversible) — placeholder for the + * port of `IgnoredContactsView.swift`. Replaced by the K3 slice C + * implementation; the signature + route + * ([org.dashfoundation.example.navigation.DashPayIgnored]) are fixed here so + * the hub's "Ignored" entry compiles now. + */ +@Composable +fun IgnoredContactsScreen(identityIdHex: String, navController: NavHostController) { + DashPayStubScaffold( + title = "Ignored", + rootTestTag = "dashpay.ignored.root", + navController = navController, + ) +} From d5cf591a0218420d823340508e03440f11d307ce Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 23:07:38 +0700 Subject: [PATCH 09/12] feat(kotlin-sdk): DashPay detail, payment, profile and list screens + parity (K3 slice C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill in the four stub screens and add the payment sheet, completing the DashPay tab port; delete the stub helper and rewrite PARITY for Views/DashPay/. - ContactDetailScreen: header (cached profile + DIP-15 account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history via the durable refreshDashPayPayments → observePayments path (sorted newest-first client-side), and alias/note editors + hide toggle via setContactInfo — surfacing the DEFERRED_UNTIL_TWO_CONTACTS / SKIPPED_WATCH_ONLY publish notices. - SendDashPayPaymentSheet (new): amount (decimal DASH → duffs) + balance check, sendPayment → txid (txidDisplayHex reversed-hex, added to the meta file), then always refreshDashPayPayments (the payment-durability invariant). No memo field (matches iOS); Balance.confirmed used as spendable. Hosted as a ModalBottomSheet from ContactDetail. - DashPayProfileScreen: read-only display + DIP-15 auto-accept QR (buildAutoAcceptQr rendered with the ZXing generateQrBitmap helper), plus an inline editor calling createOrUpdateProfile (doCreate when no profile exists). - IgnoredContactsScreen / HiddenContactsScreen: observeIgnoredSenders + unignoreContactSender / client-side hidden grouping + unhide (setContactInfo displayHidden=false, preserving alias/note). - Delete DashPayStub.kt (no stub remains). - androidTest/DashPayTabUITest.kt: ports the network-free launch-and-render flows from DashPayTabUITests.swift, adapted for the hub nav-section deviation. - PARITY.md: new Views/DashPay/ section (one row per view + deviations); FriendsView row retired; totals updated (ported 87 → 97). Gates: :app:compileDebugKotlin, :app:compileDebugAndroidTestKotlin, :sdk:testDebugUnitTest all pass. Co-Authored-By: Claude Fable 5 --- .../example/DashPayTabUITest.kt | 94 ++++ .../example/ui/dashpay/ContactDetailScreen.kt | 455 +++++++++++++++++- .../example/ui/dashpay/DashPayContactMeta.kt | 11 + .../ui/dashpay/DashPayProfileScreen.kt | 276 ++++++++++- .../example/ui/dashpay/DashPayStub.kt | 57 --- .../ui/dashpay/HiddenContactsScreen.kt | 176 ++++++- .../ui/dashpay/IgnoredContactsScreen.kt | 177 ++++++- .../ui/dashpay/SendDashPayPaymentSheet.kt | 203 ++++++++ packages/kotlin-sdk/PARITY.md | 40 +- 9 files changed, 1386 insertions(+), 103 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt delete mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt new file mode 100644 index 00000000000..29d2bea3ea4 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt @@ -0,0 +1,94 @@ +package org.dashfoundation.example + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * DashPay tab smoke tests — port of `DashPayTabUITests.swift`, adapted for + * the Kotlin hub's structure (nav-section rows instead of a Contacts/Requests + * segmented control). Network-free: they assert only the local-state gating + * the tab renders (no wallet / no identity / identity present), keyed on the + * `dashpay.*` testTags reused verbatim from iOS. Runs against the real + * bootstrap, so it also proves end-to-end startup + tab navigation. + */ +@RunWith(AndroidJUnit4::class) +class DashPayTabUITest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun anyNodeWithTag(tag: String): Boolean = + composeRule.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() + + private fun openDashPayTab() { + composeRule.waitUntil(timeoutMillis = 60_000) { + anyNodeWithTag("rootTab.dashpay") + } + composeRule.onNodeWithTag("rootTab.dashpay").performClick() + composeRule.waitForIdle() + } + + /** + * The DashPay tab must render exactly one recognized local state: + * 1. no wallet loaded → "Open Wallets" CTA (`dashpay.openWallets`) + * 2. wallet, no identity → "Open Identities" CTA (`dashpay.openIdentities`) + * 3. ≥1 eligible identity → the hub sections (`dashpay.openContacts`) + * On a fresh emulator state 1 is expected, but the test accepts any of the + * three so it stays valid with leftover local wallets — the invariant is + * "the tab renders a recognized state". + */ + @Test + fun dashPayTabRendersARecognizedState() { + openDashPayTab() + + composeRule.waitUntil(timeoutMillis = 30_000) { + anyNodeWithTag("dashpay.openWallets") || + anyNodeWithTag("dashpay.openIdentities") || + anyNodeWithTag("dashpay.openContacts") + } + + assertTrue( + "DashPay tab must render one of the states: no-wallet CTA, " + + "no-identity CTA, or the identity-present hub sections.", + anyNodeWithTag("dashpay.openWallets") || + anyNodeWithTag("dashpay.openIdentities") || + anyNodeWithTag("dashpay.openContacts"), + ) + } + + /** + * When an identity is active (state 3), the hub exposes the contact + * management + recovery entry points directly — Add Contact, Refresh, + * Ignored and Hidden. (In iOS these were toolbar buttons / a gated link; + * the Kotlin hub surfaces them as nav rows — the documented deviation.) + * Skipped when no eligible identity exists on this emulator. + */ + @Test + fun hubExposesContactEntryPointsWhenIdentityActive() { + openDashPayTab() + + composeRule.waitUntil(timeoutMillis = 30_000) { + anyNodeWithTag("dashpay.openWallets") || + anyNodeWithTag("dashpay.openIdentities") || + anyNodeWithTag("dashpay.openContacts") + } + + org.junit.Assume.assumeTrue( + "No eligible identity on this emulator — the hub sections are " + + "unreachable, so the entry points can't be asserted.", + anyNodeWithTag("dashpay.openContacts"), + ) + + assertTrue("Add Contact entry must be present", anyNodeWithTag("dashpay.addContact")) + assertTrue("Refresh must be present", anyNodeWithTag("dashpay.refresh")) + assertTrue("Ignored recovery entry must be present", anyNodeWithTag("dashpay.openIgnored")) + assertTrue("Hidden recovery entry must be present", anyNodeWithTag("dashpay.openHidden")) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt index 76200eec8c4..15b505946b0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt @@ -1,23 +1,458 @@ package org.dashfoundation.example.ui.dashpay +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity +import org.dashfoundation.dashsdk.tokens.ContactInfoPublishOutcome +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.ui.theme.appStatusColors +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.formatDuffs +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex /** - * One contact's detail + payments — placeholder for the port of - * `ContactDetailView.swift`. Replaced by the K3 slice C implementation - * (header / Send Dash / payment history / local alias-note-hide settings); - * the signature and route ([org.dashfoundation.example.navigation.DashPayContactDetail]) - * are fixed here so ContactsScreen's row navigation compiles now. + * One contact's detail — port of `ContactDetailView.swift`: profile header, + * Send Dash (via [SendDashPayPaymentSheet]), Room-driven payment history + * (refreshed through the durable `refreshDashPayPayments` path), and the + * device-synced alias / note / hide controls backed by + * `dashpay.setContactInfo` — surfacing the DIP-15 deferred / watch-only + * publish outcomes as notices. */ -@androidx.compose.runtime.Composable +@OptIn(ExperimentalMaterial3Api::class) +@Composable fun ContactDetailScreen( identityIdHex: String, contactIdHex: String, navController: NavHostController, ) { - DashPayStubScaffold( - title = "Contact", - rootTestTag = "dashpay.detail.root", - navController = navController, + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + val contactBytes = remember(contactIdHex) { contactIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = remember { DashPayContactMetaStore(context) } + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + + val allRows by remember(idBytes) { + container.database.dashpayDao().observeContactRequests(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val pairRows = remember(allRows, contactBytes) { + allRows.filter { it.contactIdentityId.contentEquals(contactBytes) } + } + val contactProfile by remember(idBytes, contactBytes) { + container.database.dashpayDao().observeContactProfile(idBytes, contactBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val payments by remember(idBytes, contactBytes) { + container.database.dashpayDao().observePayments(idBytes, contactBytes) + }.collectAsStateWithLifecycle(emptyList()) + val sortedPayments = remember(payments) { payments.sortedByDescending { it.createdAt.time } } + + val channelBroken = pairRows.any { it.paymentChannelBroken } + val localAlias = pairRows.firstNotNullOfOrNull { it.contactAlias } + val localNote = pairRows.firstNotNullOfOrNull { it.contactNote } + val contactAccountLabel = pairRows.firstOrNull { !it.isOutgoing }?.contactAccountLabel + val isHidden = pairRows.any { it.contactHidden } + val dpnsHint = remember(metaVersion, network) { metaStore.dpnsHint(network, idBytes, contactBytes) } + val displayName = dashPayContactDisplayName( + contactId = contactBytes, + alias = localAlias, + profileDisplayName = contactProfile?.displayName, + dpnsLabel = dpnsHint, + ) + + var showPaymentSheet by remember { mutableStateOf(false) } + var aliasEditorOpen by remember { mutableStateOf(false) } + var noteEditorOpen by remember { mutableStateOf(false) } + var isRefreshingPayments by remember { mutableStateOf(false) } + var paymentsError by remember { mutableStateOf(null) } + var isSavingContactInfo by remember { mutableStateOf(false) } + var contactInfoError by remember { mutableStateOf(null) } + var publishNotice by remember { mutableStateOf(null) } + + fun refreshPayments() { + val m = manager ?: return + val wid = walletId ?: run { paymentsError = "Identity has no wallet association"; return } + if (isRefreshingPayments) return + isRefreshingPayments = true + paymentsError = null + scope.launch { + try { + m.refreshDashPayPayments(wid, idBytes) + } catch (e: Exception) { + paymentsError = "Payment refresh failed: ${e.message ?: "unknown error"}" + } finally { + isRefreshingPayments = false + } + } + } + + fun saveContactInfo(alias: String?, note: String?, hidden: Boolean) { + val m = manager ?: return + val wid = walletId ?: run { contactInfoError = "No wallet available for this identity"; return } + val wallet = m.wallet(forWalletId = wid) ?: run { + contactInfoError = "No wallet available for this identity" + return + } + isSavingContactInfo = true + contactInfoError = null + publishNotice = null + scope.launch { + try { + val outcome = wallet.dashpay.setContactInfo( + identityId = idBytes, + contactId = contactBytes, + alias = alias?.ifBlank { null }, + note = note?.ifBlank { null }, + displayHidden = hidden, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + publishNotice = when (outcome) { + ContactInfoPublishOutcome.PUBLISHED -> null + ContactInfoPublishOutcome.DEFERRED_UNTIL_TWO_CONTACTS -> + "Saved on this device. It will sync to your other devices once this " + + "identity has two or more contacts." + ContactInfoPublishOutcome.SKIPPED_WATCH_ONLY -> + "Saved on this device only — this watch-only identity can't publish to Platform." + } + } catch (e: Exception) { + contactInfoError = "Save failed: ${e.message ?: "unknown error"}" + } finally { + isSavingContactInfo = false + } + } + } + + LaunchedEffect(Unit) { refreshPayments() } + val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) + .collectAsStateWithLifecycle(false) + LaunchedEffect(isSyncing) { if (!isSyncing) refreshPayments() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header + FormSection { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(contactProfile?.avatarUrl, displayName, size = 56.dp) + Column(Modifier.weight(1f)) { + Text(displayName, style = MaterialTheme.typography.titleLarge) + dpnsHint?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + Base58.encode(contactBytes).take(20) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + contactProfile?.publicMessage?.trim()?.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } + } + } + localNote?.let { LabeledContent("Note", it) } + contactAccountLabel?.let { LabeledContent("Their account", it) } + } + + // Send + FormSection { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !channelBroken) { showPaymentSheet = true } + .testTag("dashpay.detail.sendDash"), + leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) }, + headlineContent = { + Text( + "Send Dash", + color = if (channelBroken) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + }, + ) + if (channelBroken) { + Text( + "Payment channel broken — ask the contact to send a new request", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + + // Payments + FormSection(title = "Payments (${sortedPayments.size})") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { refreshPayments() }, + enabled = !isRefreshingPayments, + modifier = Modifier.testTag("dashpay.detail.refreshPayments"), + ) { + if (isRefreshingPayments) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Refresh, contentDescription = "Refresh payments") + } + } + } + if (sortedPayments.isEmpty()) { + Text( + if (isRefreshingPayments) "Loading payments…" else "No payments yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + sortedPayments.forEach { PaymentHistoryRow(it) } + } + paymentsError?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + + // Local settings + FormSection(title = "Contact settings") { + ListItem( + modifier = Modifier.fillMaxWidth().clickable { aliasEditorOpen = true }.testTag("dashpay.detail.aliasEdit"), + headlineContent = { Text("Alias") }, + trailingContent = { Text(localAlias ?: "None", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + ) + ListItem( + modifier = Modifier.fillMaxWidth().clickable { noteEditorOpen = true }.testTag("dashpay.detail.noteEdit"), + headlineContent = { Text("Note") }, + trailingContent = { Text(if (localNote == null) "None" else "Edit", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Hide contact") + Switch( + checked = isHidden, + onCheckedChange = { saveContactInfo(localAlias, localNote, it) }, + enabled = !isSavingContactInfo, + modifier = Modifier.testTag("dashpay.detail.hideToggle"), + ) + } + if (isSavingContactInfo) { + Text("Saving…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + contactInfoError?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + publishNotice?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary) + } + Text( + "Alias, note and hide are encrypted and synced to your other devices via " + + "Platform once this identity has two or more contacts.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showPaymentSheet && manager != null && walletId != null) { + ModalBottomSheet(onDismissRequest = { showPaymentSheet = false }) { + SendDashPayPaymentSheet( + manager = manager!!, + walletId = walletId!!, + senderIdentityId = idBytes, + contactId = contactBytes, + contactDisplayName = displayName, + contactDpnsName = dpnsHint, + onSent = { refreshPayments() }, + onClose = { showPaymentSheet = false }, + ) + } + } + + if (aliasEditorOpen) { + LocalFieldEditor( + title = "Alias", + prompt = "e.g. Mom", + initialValue = localAlias ?: "", + identifierPrefix = "dashpay.detail.alias", + onDismiss = { aliasEditorOpen = false }, + onSave = { value -> + aliasEditorOpen = false + saveContactInfo(value, localNote, isHidden) + }, + ) + } + if (noteEditorOpen) { + LocalFieldEditor( + title = "Note", + prompt = "Anything to remember about this contact", + initialValue = localNote ?: "", + identifierPrefix = "dashpay.detail.note", + onDismiss = { noteEditorOpen = false }, + onSave = { value -> + noteEditorOpen = false + saveContactInfo(localAlias, value, isHidden) + }, + ) + } +} + +@Composable +private fun PaymentHistoryRow(payment: DashpayPaymentEntity) { + val sent = payment.directionRaw == 0 + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + if (sent) "Sent" else "Received", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + payment.txid.take(16) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + payment.memo?.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + Column(horizontalAlignment = Alignment.End) { + Text(formatDuffs(payment.amountDuffs), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text( + statusLabel(payment.statusRaw), + style = MaterialTheme.typography.bodySmall, + color = statusColor(payment.statusRaw), + ) + } + } +} + +@Composable +private fun statusColor(statusRaw: Int) = when (statusRaw) { + 1 -> appStatusColors.success + 2 -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.tertiary +} + +private fun statusLabel(statusRaw: Int) = when (statusRaw) { + 1 -> "Confirmed" + 2 -> "Failed" + else -> "Pending" +} + +@Composable +private fun LocalFieldEditor( + title: String, + prompt: String, + initialValue: String, + identifierPrefix: String, + onDismiss: () -> Unit, + onSave: (String?) -> Unit, +) { + var value by remember { mutableStateOf(initialValue) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth().testTag("$identifierPrefix.field"), + placeholder = { Text(prompt) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { onSave(value.trim().ifEmpty { null }) }, + modifier = Modifier.testTag("$identifierPrefix.save"), + ) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss, modifier = Modifier.testTag("$identifierPrefix.cancel")) { + Text("Cancel") + } + }, ) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt index 3941ea10539..45400b36214 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt @@ -124,6 +124,17 @@ fun dashPayContactDisplayName( return contactId.toHex().take(12) + "…" } +// ── Txid display order ─────────────────────────────────────────────────── + +/** + * Hex-encode a raw 32-byte txid in canonical (reversed) display order — + * port of `txidDisplayHex` in `DashPayContactMeta.swift`. The FFI hands back + * wire/internal byte order, so a bare hex reads reversed from block + * explorers; this flip lines it up with the rest of the app's tx display. + */ +fun txidDisplayHex(txid: ByteArray): String = + txid.reversed().joinToString("") { "%02x".format(it) } + // ── Avatar ─────────────────────────────────────────────────────────────── /** diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt index 784c07cd6af..832d6a3481b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -1,20 +1,276 @@ package org.dashfoundation.example.ui.dashpay +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.generateQrBitmap +import org.dashfoundation.example.util.hexToBytes /** - * Read-only own-profile view + DIP-15 auto-accept QR — placeholder for the - * port of `DashPayProfileView.swift`. Replaced by the K3 slice C - * implementation; the signature + route - * ([org.dashfoundation.example.navigation.DashPayProfile]) are fixed here so - * the hub's "Your Profile" entry compiles now. + * Own DashPay profile — port of `DashPayProfileView.swift` plus its companion + * editor: read-only display (avatar / name / DPNS / public message / id), the + * DIP-15 auto-accept QR (via `buildAutoAcceptQr`, rendered with the ZXing + * `generateQrBitmap` helper), and an inline edit mode calling + * `createOrUpdateProfile` (doCreate when no profile exists yet). */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController) { - DashPayStubScaffold( - title = "Your Profile", - rootTestTag = "dashpay.profile.root", - navController = navController, - ) + val container = LocalAppContainer.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + + var profile by remember { mutableStateOf(null) } + var profileExists by remember { mutableStateOf(false) } + var qrUri by remember { mutableStateOf(null) } + var qrError by remember { mutableStateOf(null) } + + var isEditing by remember { mutableStateOf(false) } + var displayNameField by remember { mutableStateOf("") } + var publicMessageField by remember { mutableStateOf("") } + var avatarUrlField by remember { mutableStateOf("") } + var isSaving by remember { mutableStateOf(false) } + var saveError by remember { mutableStateOf(null) } + + suspend fun loadProfile() { + val w = wallet ?: return + val raw = w.dashpay.getProfile(idBytes) + profileExists = raw != null + profile = parseDashPayProfile(raw) + } + + LaunchedEffect(wallet) { + val w = wallet ?: return@LaunchedEffect + loadProfile() + val m = manager + if (m != null && qrUri == null && qrError == null) { + val username = (identity?.mainDpnsName ?: identity?.dpnsName)?.trim().orEmpty() + try { + qrUri = w.dashpay.buildAutoAcceptQr(idBytes, username, m.mnemonicResolverHandle) + } catch (e: Exception) { + qrError = "Couldn't build the QR: ${e.message ?: "unknown error"}" + } + } + } + + val displayName = profile?.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: (identity?.mainDpnsName ?: identity?.dpnsName) + ?: (Base58.encode(idBytes).take(12) + "…") + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Your Profile") }, + navigationIcon = { + TextButton( + onClick = { navController.popBackStack() }, + modifier = Modifier.testTag("dashpay.profile.done"), + ) { Text("Done") } + }, + actions = { + TextButton( + onClick = { + if (!isEditing) { + displayNameField = profile?.displayName.orEmpty() + publicMessageField = profile?.publicMessage.orEmpty() + avatarUrlField = profile?.avatarUrl.orEmpty() + saveError = null + } + isEditing = !isEditing + }, + modifier = Modifier.testTag("dashpay.profile.edit"), + ) { Text(if (isEditing) "Cancel" else "Edit") } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (isEditing) { + FormSection(title = "Edit profile") { + OutlinedTextField( + value = displayNameField, + onValueChange = { displayNameField = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Display name") }, + singleLine = true, + ) + OutlinedTextField( + value = publicMessageField, + onValueChange = { publicMessageField = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Public message") }, + ) + OutlinedTextField( + value = avatarUrlField, + onValueChange = { avatarUrlField = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Avatar URL") }, + singleLine = true, + ) + saveError?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + SubmitButton( + text = "Save", + isLoading = isSaving, + enabled = !isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + val m = manager ?: return@SubmitButton + val w = wallet ?: return@SubmitButton + isSaving = true + saveError = null + scope.launch { + try { + w.dashpay.createOrUpdateProfile( + identityId = idBytes, + displayName = displayNameField.trim().ifEmpty { null }, + publicMessage = publicMessageField.trim().ifEmpty { null }, + avatarUrl = avatarUrlField.trim().ifEmpty { null }, + doCreate = !profileExists, + signerHandle = m.signerHandle, + ) + loadProfile() + isEditing = false + } catch (e: Exception) { + saveError = e.message ?: "Save failed" + } finally { + isSaving = false + } + } + } + } + } else { + FormSection { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DashPayAvatar(profile?.avatarUrl, displayName, size = 96.dp) + Text(displayName, style = MaterialTheme.typography.titleLarge) + (identity?.mainDpnsName ?: identity?.dpnsName)?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + } + profile?.publicMessage?.trim()?.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } + } + } + + FormSection(title = "Identity") { + Text( + Base58.encode(idBytes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FormSection(title = "Add me (DIP-15 QR)") { + val uri = qrUri + when { + uri != null -> { + val bitmap = remember(uri) { generateQrBitmap(uri) } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Auto-accept QR", + modifier = Modifier + .size(200.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(8.dp), + ) + } + Text( + "Scan to send me a contact request — auto-accepted for 1 hour.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + modifier = Modifier.testTag("dashpay.profile.qrURI"), + ) + } + } + qrError != null -> Text( + qrError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + else -> Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text("Generating QR…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt deleted file mode 100644 index 3e294f79576..00000000000 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dashfoundation.example.ui.dashpay - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.navigation.NavHostController - -/** - * Shared placeholder scaffold for the DashPay screens still owned by K3 - * slice C (ContactDetail / Profile / Ignored / Hidden). Gives each a titled - * bar + back button + a root testTag so navigation from the hub / Contacts - * works end-to-end before those screens land. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun DashPayStubScaffold( - title: String, - rootTestTag: String, - navController: NavHostController, -) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(title) }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Box( - modifier = Modifier.fillMaxSize().padding(padding).testTag(rootTestTag), - contentAlignment = Alignment.Center, - ) { - Text( - "Coming in the next milestone.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt index 64f40bf97cf..07cdccc5dd2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt @@ -1,20 +1,176 @@ package org.dashfoundation.example.ui.dashpay +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex /** - * Hidden established-contacts list (reversible, cross-device) — placeholder - * for the port of `HiddenContactsView.swift`. Replaced by the K3 slice C - * implementation; the signature + route - * ([org.dashfoundation.example.navigation.DashPayHidden]) are fixed here so - * ContactsScreen's "Hidden contacts" link and the hub entry compile now. + * Hidden established-contacts list — port of `HiddenContactsView.swift`. The + * exact complement of the Contacts list: established pairs with any row + * `contactHidden`. Unhide republishes `contactInfo` with `displayHidden = + * false` (preserving alias/note) then kicks a sync. Optimistic removal + + * per-row in-flight/error. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostController) { - DashPayStubScaffold( - title = "Hidden", - rootTestTag = "dashpay.hidden.root", - navController = navController, - ) + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val ownerBytes = remember(ownerIdentityIdHex) { ownerIdentityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = remember { DashPayContactMetaStore(context) } + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val identity by remember(ownerBytes) { + container.database.identityDao().observeByIdentityId(ownerBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + + val rows by remember(ownerBytes) { + container.database.dashpayDao().observeContactRequests(ownerBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(ownerBytes) { + container.database.dashpayDao().observeContactProfiles(ownerBytes) + }.collectAsStateWithLifecycle(emptyList()) + val profilesByHex = remember(contactProfiles) { contactProfiles.associateBy { it.contactIdentityId.toHex() } } + + var inFlightIds by remember { mutableStateOf(emptySet()) } + var removedOverlayIds by remember { mutableStateOf(emptySet()) } + var rowErrors by remember { mutableStateOf(emptyMap()) } + + val hiddenContacts = remember(rows, profilesByHex, removedOverlayIds, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + val hasOut = group.any { it.isOutgoing } + val hasIn = group.any { !it.isOutgoing } + if (!hasOut || !hasIn || group.none { it.contactHidden }) return@mapNotNull null + if (removedOverlayIds.contains(hex)) return@mapNotNull null + val contactId = group.first().contactIdentityId + val profile = profilesByHex[hex] + val alias = group.firstNotNullOfOrNull { it.contactAlias } + HiddenContactItem( + contactId = contactId, + displayName = dashPayContactDisplayName( + contactId = contactId, + alias = alias, + profileDisplayName = profile?.displayName, + dpnsLabel = metaStore.dpnsHint(network, ownerBytes, contactId), + ), + avatarUrl = profile?.avatarUrl, + alias = alias, + note = group.firstNotNullOfOrNull { it.contactNote }, + ) + } + .sortedBy { it.displayName.lowercase() } + } + + fun unhide(contact: HiddenContactItem) { + val m = manager ?: return + val w = wallet ?: return + val hex = contact.contactId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + w.dashpay.setContactInfo( + identityId = ownerBytes, + contactId = contact.contactId, + alias = contact.alias, + note = contact.note, + displayHidden = false, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + removedOverlayIds = removedOverlayIds + hex + kickDashPaySync(scope, m) + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Unhide failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Hidden") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (hiddenContacts.isEmpty()) { + item { + DashPayEmptyRow( + title = "No hidden contacts", + message = "Contacts you hide stay payable but leave your Contacts list, " + + "and are listed here so you can unhide them.", + ) + } + } else { + items(hiddenContacts, key = { it.contactId.toHex() }) { contact -> + val hex = contact.contactId.toHex() + ReversibleContactRow( + displayName = contact.displayName, + avatarUrl = contact.avatarUrl, + isInFlight = inFlightIds.contains(hex), + errorMessage = rowErrors[hex], + actionLabel = "Unhide", + actionTestTag = "dashpay.hidden.unhide", + onAction = { unhide(contact) }, + ) + } + } + } + } } + +/** UI model for one hidden contact — carries alias/note so unhide can republish them. */ +private data class HiddenContactItem( + val contactId: ByteArray, + val displayName: String, + val avatarUrl: String?, + val alias: String?, + val note: String?, +) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt index 3018463a63d..a2c9de60e4f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt @@ -1,20 +1,177 @@ package org.dashfoundation.example.ui.dashpay +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex /** - * Ignored-senders list (per-sender mute, reversible) — placeholder for the - * port of `IgnoredContactsView.swift`. Replaced by the K3 slice C - * implementation; the signature + route - * ([org.dashfoundation.example.navigation.DashPayIgnored]) are fixed here so - * the hub's "Ignored" entry compiles now. + * Ignored-senders list — port of `IgnoredContactsView.swift`. Lists every + * sender this identity has ignored (per-sender mute, reversible, local-only) + * with an Un-ignore action; names/avatars resolve from the cached + * contact-profile Room rows. Optimistic removal + per-row in-flight/error. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun IgnoredContactsScreen(identityIdHex: String, navController: NavHostController) { - DashPayStubScaffold( - title = "Ignored", - rootTestTag = "dashpay.ignored.root", - navController = navController, - ) + val container = LocalAppContainer.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + + val ignoredRows by remember(idBytes) { + container.database.dashpayDao().observeIgnoredSenders(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(idBytes) { + container.database.dashpayDao().observeContactProfiles(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val profilesByHex = remember(contactProfiles) { contactProfiles.associateBy { it.contactIdentityId.toHex() } } + + var inFlightIds by remember { mutableStateOf(emptySet()) } + var removedOverlayIds by remember { mutableStateOf(emptySet()) } + var rowErrors by remember { mutableStateOf(emptyMap()) } + + val visibleRows = remember(ignoredRows, removedOverlayIds) { + ignoredRows + .filter { !removedOverlayIds.contains(it.ignoredSenderId.toHex()) } + .sortedByDescending { it.ignoredAt.time } + } + + fun unignore(senderId: ByteArray) { + val w = wallet ?: return + val hex = senderId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + w.dashpay.unignoreContactSender(ourIdentityId = idBytes, contactIdentityId = senderId) + removedOverlayIds = removedOverlayIds + hex + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Un-ignore failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Ignored") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (visibleRows.isEmpty()) { + item { + DashPayEmptyRow( + title = "No ignored contacts", + message = "Senders you ignore are hidden from your pending requests and " + + "listed here so you can un-ignore them.", + ) + } + } else { + items(visibleRows, key = { it.ignoredSenderId.toHex() }) { row -> + val hex = row.ignoredSenderId.toHex() + val profile = profilesByHex[hex] + ReversibleContactRow( + displayName = dashPayContactDisplayName(row.ignoredSenderId, null, profile?.displayName, null), + avatarUrl = profile?.avatarUrl, + isInFlight = inFlightIds.contains(hex), + errorMessage = rowErrors[hex], + actionLabel = "Un-ignore", + actionTestTag = "dashpay.ignored.unignore", + onAction = { unignore(row.ignoredSenderId) }, + ) + } + } + } + } +} + +/** + * Shared reversible-mute row (avatar + name + spinner|action + inline error), + * used by the Ignored and Hidden lists. + */ +@Composable +internal fun ReversibleContactRow( + displayName: String, + avatarUrl: String?, + isInFlight: Boolean, + errorMessage: String?, + actionLabel: String, + actionTestTag: String, + onAction: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(avatarUrl, displayName) + Text(displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + if (isInFlight) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + OutlinedButton(onClick = onAction, modifier = Modifier.testTag(actionTestTag)) { + Text(actionLabel) + } + } + } + errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt new file mode 100644 index 00000000000..7000629c291 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt @@ -0,0 +1,203 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.ui.theme.appStatusColors +import org.dashfoundation.example.util.formatDuffs +import org.dashfoundation.example.util.parseDashToDuffs + +/** + * Send-a-Dash-payment sheet content — port of `SendDashPayPaymentSheet.swift`, + * hosted inside a `ModalBottomSheet` by [ContactDetailScreen]. Recipient row + + * amount (decimal DASH → duffs) with a balance check, then + * `dashpay.sendPayment`. On success it fires [onSent] (the payment-durability + * refresh in the parent), kicks a sync, and auto-closes after a short settle. + * + * No memo field: DashPay payments are plain Core-chain transactions with no + * on-chain memo slot (matching iOS), so `memo = null`. + */ +@Composable +fun SendDashPayPaymentSheet( + manager: PlatformWalletManager, + walletId: ByteArray, + senderIdentityId: ByteArray, + contactId: ByteArray, + contactDisplayName: String, + contactDpnsName: String?, + onSent: () -> Unit, + onClose: () -> Unit, +) { + val scope = rememberCoroutineScope() + val wallet = remember(manager, walletId) { manager.wallet(forWalletId = walletId) } + + var amountText by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var successTxidHex by remember { mutableStateOf(null) } + var recipientProfile by remember { mutableStateOf(null) } + var senderBalanceDuffs by remember { mutableStateOf(null) } + + LaunchedEffect(wallet) { + val w = wallet ?: return@LaunchedEffect + recipientProfile = parseDashPayProfile(w.dashpay.getContactProfile(senderIdentityId, contactId)) + // Kotlin Balance has no `spendable`; confirmed is the spendable slice. + senderBalanceDuffs = runCatching { w.balance().confirmed }.getOrNull() + } + + val amountDuffs = remember(amountText) { parseDashToDuffs(amountText) } + val exceedsBalance = senderBalanceDuffs?.let { bal -> amountDuffs?.let { it > bal } } ?: false + val recipientName = recipientProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: contactDpnsName?.takeIf { it.isNotBlank() } + ?: contactDisplayName + val canSend = amountDuffs != null && amountDuffs > 0 && !exceedsBalance && + senderBalanceDuffs != 0L && !isSending + + fun send() { + val w = wallet ?: run { errorMessage = "No wallet available for this identity"; return } + val duffs = amountDuffs ?: return + isSending = true + errorMessage = null + scope.launch { + try { + val txid = w.dashpay.sendPayment( + fromIdentityId = senderIdentityId, + toContactIdentityId = contactId, + amountDuffs = duffs, + coreSignerHandle = manager.mnemonicResolverHandle, + memo = null, + ) + successTxidHex = txid?.let { txidDisplayHex(it) } + onSent() + kickDashPaySync(scope, manager) + delay(1500) + onClose() + } catch (e: Exception) { + errorMessage = e.message ?: "Send failed" + } finally { + isSending = false + } + } + } + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Send Dash", style = MaterialTheme.typography.titleLarge) + + // Recipient + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(recipientProfile?.avatarUrl, recipientName) + Text(recipientName, style = MaterialTheme.typography.titleMedium) + } + + if (senderBalanceDuffs == 0L) { + Text( + "Your balance is 0 DASH — top up your wallet before sending.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it }, + modifier = Modifier.fillMaxWidth().testTag("dashpay.send.amount"), + label = { Text("Amount (DASH)") }, + placeholder = { Text("0.001") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + } + senderBalanceDuffs?.let { bal -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Your balance", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + formatDuffs(bal), + style = MaterialTheme.typography.bodySmall, + color = if (exceedsBalance) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (amountText.isNotEmpty() && amountDuffs == null) { + Text( + "Enter a valid decimal Dash amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else if (exceedsBalance) { + Text( + "Amount exceeds your spendable balance", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + successTxidHex?.let { hex -> + Text( + "Sent! txid: ${hex.take(16)}…", + style = MaterialTheme.typography.bodySmall, + color = appStatusColors.success, + ) + } + errorMessage?.let { msg -> + Text(msg, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = onClose, + enabled = !isSending, + modifier = Modifier.testTag("dashpay.send.cancel"), + ) { Text("Cancel") } + if (isSending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + androidx.compose.material3.Button( + onClick = { send() }, + enabled = canSend, + modifier = Modifier.testTag("dashpay.send.confirm"), + ) { Text("Send") } + } + } + } +} diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 061af19d7f0..3bebbb4c908 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -34,7 +34,7 @@ Status legend: | DocumentTypeDetailsView.swift | ui/contracts/DocumentTypeDetailsScreen.kt · `DocumentTypeDetail` | ported | | DocumentWithPriceView.swift | ui/contracts/DocumentWithPriceScreen.kt · `DocumentWithPrice` | ported — debounced document-id probe (price / owner / ownership badging via `Documents.fetch`), plus the purchase (`DocumentTransactions.purchase` → `platform_wallet_document_purchase`) and owner set-price (`DocumentTransactions.setPrice` → `platform_wallet_document_set_price`) submit flows that live in `PurchaseDocumentView` / the set-price sheet on iOS, hosted as one screen; entries from DocumentsScreen rows ("Price…") and the transition catalog | | DocumentsView.swift | ui/contracts/DocumentsScreen.kt · `Documents` | ported (query role; viewer role in DocumentFieldsScreen; the row-level Purchase… / Set Price… actions drill into DocumentWithPriceScreen; create / replace / delete / transfer stay unbridged — see TransitionDetailView) | -| ~~FriendsView.swift~~ (deleted upstream) | ui/identity/FriendsScreen.kt · `Friends` | **in migration** — the Swift view was deleted by PR #3841 and replaced by the first-class DashPay tab (`Views/DashPay/`, 10 views: DashPayTabView, ContactsView, ContactRequestsView, AddContactView, ContactDetailView, SendDashPayPaymentSheet, DashPayProfileView, IgnoredContactsView, HiddenContactsView, DashPayContactMeta), none of which are ported yet. FriendsScreen still covers a slice (sync / list / send / accept / ignore over the 17 bridged exports, reconciled with the post-#3841 FFI in `2298a2059f`); it is retired and superseded per `docs/dashpay/KOTLIN_MIGRATION_SPEC.md` (milestones K1–K3) | +| ~~FriendsView.swift~~ (deleted upstream) | — (retired) | retired — deleted by PR #3841 and superseded by the first-class DashPay tab; `FriendsScreen.kt` and its `Friends` route were removed in milestone K3. See the **Views/DashPay/** section below | | FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow | | TransferPlatformAddressView.swift (ADDR-02, #3923) | ui/credits/TransferPlatformAddressScreen.kt · `TransferPlatformAddress` | ported — wallet-signed DIP-17 credit transfer via the now-bridged `platform_address_wallet_transfer` (`ManagedPlatformWallet.transferCredits`, AUTO selection, null inputs/fee-strategy); source account + destination (own-wallet / external P2PKH hash) + amount only; gate reads version-locked `minInput`/`minOutput` via `walletPlatformAddressMinAmounts`. Launched from WalletDetailScreen's Platform Credits section | | WithdrawPlatformAddressView.swift (ADDR-04, #3923) | ui/credits/WithdrawPlatformAddressScreen.kt · `WithdrawPlatformAddress` | ported — wallet-signed full-balance DIP-17 withdrawal to a Core L1 address via the now-bridged `platform_address_wallet_withdraw_to_address` (`ManagedPlatformWallet.withdrawCredits`); submit gated on `platform_address_wallet_preflight_withdrawal` (`preflightWithdrawal`, off the main thread on `Dispatchers.IO`), and when the gate refuses, the advisory "why not" from `preflightWithdrawalReason` (`platform_address_wallet_preflight_withdrawal_reason`) renders under the status row; Fibonacci fee-rate picker mirrors `WithdrawalCoreFeeRates`. Launched from WalletDetailScreen's Platform Credits section | @@ -81,6 +81,36 @@ Status legend: | WalletMemoryExplorerView.swift | ui/diagnostics/WalletMemoryExplorerScreen.kt · `WalletMemoryExplorer` | partial — wallets map, balances, SPV progress/tip, `is*SyncRunning` liveness live; per-wallet drill-downs deferred on `platform_wallet_manager_*` snapshot exports | | WithdrawCreditsView.swift | ui/credits/WithdrawCreditsScreen.kt · `WithdrawCredits` | ported | +## Views/DashPay/ + +The first-class DashPay tab added by PR #3841 (replacing the old +`FriendsView`), ported in milestone K3. All screens live under +`ui/dashpay/`; testTags reuse the iOS `dashpay.*` accessibility identifiers +verbatim. Documented deviations from iOS are listed after the table. + +| Swift file | Android file / route | Status | +| --- | --- | --- | +| DashPayTabView.swift | ui/dashpay/DashPayTabScreen.kt · `DashPayHome` | ported — wallet-backed identity picker (`observeWalletOwnedByNetwork`), received-from-contacts balance (`accountBalances` typeTag 12, re-read after each sweep), the seedless-unlock banner off `dashPayUnlockStatus` (seed-mismatch / draining / pending-account-builds → `unlockWalletFromKeystore`), pull-to-refresh + toolbar refresh (`dashPaySyncNow`). Adapted: the Contacts/Requests **segmented control is replaced by nav-section rows** (`dashpay.openContacts` / `dashpay.openRequests` / …), so `dashpay.segment` / `dashpay.profileHeader` / `dashpay.usernamePrompt` are not reproduced | +| ContactsView.swift | ui/dashpay/ContactsScreen.kt · `DashPayContacts` | ported — established-contacts both-direction grouping over Room `observeContactRequests`, hidden excluded, cached name/avatar via `observeContactProfiles` + the meta store, search, Hidden recovery link, pull-to-refresh | +| ContactRequestsView.swift | ui/dashpay/ContactRequestsScreen.kt · `DashPayRequests` | ported — incoming (Accept via `acceptIncomingRequest` / Ignore via `ignoreContactSender`, per-row in-flight + inline error + optimistic-removal overlay) and outgoing pending; incoming avatars are never loaded (IP-leak avoidance). Adapted: the cross-screen optimistic-**sent** overlay is dropped (AddContact is a separate route, not a shared `@Binding`) | +| AddContactView.swift | ui/dashpay/AddContactScreen.kt · `DashPayAddContact` | ported — 300 ms-debounced DPNS search (`searchDpnsNames`), paste-id (32-byte base58 gate), preview card, optional account label, collision dialog (Accept vs Continue anyway), and scan-to-send via `sendContactRequestFromQr` (`dashpay.addViaQR` toolbar → the shared QR scanner). Records the DPNS hint into the meta store on success | +| ContactDetailView.swift | ui/dashpay/ContactDetailScreen.kt · `DashPayContactDetail` | ported — header (cached profile + account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history (durable `refreshDashPayPayments` → `observePayments`, sorted newest-first client-side), and alias/note editors + hide toggle via `setContactInfo` **surfacing the DIP-15 deferred / watch-only publish notices** | +| SendDashPayPaymentSheet.swift | ui/dashpay/SendDashPayPaymentSheet.kt | ported — hosted as a `ModalBottomSheet` from ContactDetail; amount (decimal DASH → duffs) + balance check, `sendPayment` → txid (reversed-hex via `txidDisplayHex`), then always `refreshDashPayPayments` (the durability invariant). No memo field (matches iOS — DashPay payments have no on-chain memo slot). Uses `Balance.confirmed` as spendable (Kotlin `Balance` has no `spendable`) | +| DashPayProfileView.swift | ui/dashpay/DashPayProfileScreen.kt · `DashPayProfile` | ported — read-only display (avatar / name / DPNS / public message / id) + DIP-15 auto-accept QR (`buildAutoAcceptQr` rendered with the ZXing `generateQrBitmap` helper); folds in the companion editor (inline edit mode → `createOrUpdateProfile`, doCreate when no profile exists) | +| IgnoredContactsView.swift | ui/dashpay/IgnoredContactsScreen.kt · `DashPayIgnored` | ported — `observeIgnoredSenders` list with Un-ignore (`unignoreContactSender`), optimistic removal + per-row in-flight/error | +| HiddenContactsView.swift | ui/dashpay/HiddenContactsScreen.kt · `DashPayHidden` | ported — client-side hidden-established grouping with Unhide (`setContactInfo displayHidden=false`, preserving alias/note) + sync kick | +| DashPayContactMeta.swift | ui/dashpay/DashPayContactMeta.kt (+ DashPayJson.kt) | ported — `DashPayContactMetaStore` (SharedPreferences, same key shape, `version` StateFlow), `dashPayContactDisplayName` precedence, `txidDisplayHex`, the `DashPayAvatar` composable (Coil), plus the org.json parsers for profile / DPNS-search / account-balance / payment reads | + +**Deviations from iOS (all intentional, K3):** + +1. **Hub uses nav-section rows, not a segmented control.** The DashPay tab navigates to `DashPayContacts` / `DashPayRequests` routes instead of embedding a `[Contacts | Requests]` picker — a cleaner Compose fit. `dashpay.segment` / `dashpay.profileHeader` / `dashpay.usernamePrompt` are not reproduced; Ignored + Hidden are surfaced as hub rows (iOS gated Hidden behind a Contacts link, which the Kotlin ContactsScreen also keeps). +2. **Active-identity selection is in-memory** (`remember(network)`) — resets on network switch (matching iOS) but is not persisted across launches (iOS uses `@AppStorage`). +3. **Optimistic-sent overlay dropped** — AddContact is its own route, so a just-sent request appears once the post-send sync persists its outgoing row. +4. **Incoming-request avatars are always null** (IP-leak avoidance), matching iOS. +5. **Signing** uses the manager's `signerHandle` + `mnemonicResolverHandle` directly (the KeystoreSigner behind them is auth-gated internally) — no per-screen biometric prompt. + +**Tests:** `androidTest/DashPayTabUITest.kt` ports the network-free launch-and-render flows from `DashPayTabUITests.swift` (recognized-state gating + hub entry points), adapted for the nav-section deviation. + ## Views/Components/ | Swift file | Android file | Status | @@ -129,13 +159,11 @@ Status legend: ## Totals -- **ported**: 87 (of the 90 pre-#3841 Swift views; the FriendsView row above - no longer counts — its Swift source is deleted) +- **ported**: 97 (87 pre-#3841 views + the 10 `Views/DashPay/` views, ported + in milestone K3; the retired FriendsView row does not count — its Swift + source is deleted) - **partial**: 2 (TransitionDetailView — 5 of 23 catalog entries lack backing FFIs: dataContractUpdate, documentCreate/Replace/Delete/Transfer; WalletMemoryExplorerView — asset-lock drill-down summary only) - **deferred**: 0 -- **in migration**: the DashPay tab (10 `Views/DashPay/` views added by - PR #3841) — see `docs/dashpay/KOTLIN_MIGRATION_SPEC.md`; this table gets - its `Views/DashPay/` section when milestone K3 lands Every partial/deferred row names the missing FFI export; the app surfaces the same name in a dialog at the point of use (grep for From 2088a4542ba8a01ca8d58d610833ef21a804e066 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 23:37:01 +0700 Subject: [PATCH 10/12] fix(kotlin-sdk): fold K3 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-lens review (Compose concurrency + Swift parity) fixes on the DashPay tab. No behavior regressions; gates green. MUST-FIX - M1 payment sheet: wrap the send + post-broadcast bookkeeping (txid, onSent → durable refreshDashPayPayments) in withContext(NonCancellable), and block interactive dismissal of the host ModalBottomSheet while a send is in flight (sheetState confirmValueChange + guarded onDismissRequest via a new onSendingChange callback). Prevents a swipe-dismiss-mid-send from losing the confirmation/refresh after the (uncancellable) broadcast → double-payment risk. SHOULD-FIX - S1: register a NativeCleaner GC backstop on ContactRequestRef / EstablishedContactRef (matching Sdk.kt) so a cancellation-dropped ref can't leak its native handle for the process lifetime. - S2: drop ContactRequestsScreen's blanket LaunchedEffect(isSyncing) overlay wipe; the rows-driven prune already scopes removal to the action's own change, so an unrelated sweep no longer flashes accepted/ignored rows back. - S3: make DashPayContactMetaStore a single shared instance on AppContainer; the five screens now read it, so a write's version bump invalidates every reader (a per-screen instance only recomposed its own creator). - S4: guard the unlock banner with a local isUnlocking (disable the button while in flight) so a double-tap can't stack concurrent unlockWalletFromKeystore. - Parity S1: Dashpay.buildAutoAcceptQr username String? → non-optional String (Swift parity; the FFI check_ptr!-rejects null so the default was a latent throw); corrected the DashpayNative.kt + rs-unified-sdk-jni JNI doc claims. NOTES folded in: N1 (unconditional composable calls in UnlockBanner), N2 (remember the fallback dashPaySyncIsSyncing flow), N6 (prune Ignored/Hidden overlays against Room like Requests), N7 (encode the profile QR off the main thread via produceState). PARITY.md N7: added dashpay.profileHeader.setup to the not-reproduced tags + noted the AlertDialog alias/note editors. Gates: cargo check -p rs-unified-sdk-jni + :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :sdk:compileDebugKotlin :sdk:testDebugUnitTest all pass. Co-Authored-By: Claude Fable 5 --- .../dashfoundation/example/di/AppContainer.kt | 10 +++++ .../example/ui/dashpay/AddContactScreen.kt | 4 +- .../example/ui/dashpay/ContactDetailScreen.kt | 21 +++++++--- .../ui/dashpay/ContactRequestsScreen.kt | 21 ++++------ .../example/ui/dashpay/ContactsScreen.kt | 4 +- .../ui/dashpay/DashPayProfileScreen.kt | 12 +++++- .../example/ui/dashpay/DashPayTabScreen.kt | 40 ++++++++++++++----- .../ui/dashpay/HiddenContactsScreen.kt | 14 +++++-- .../ui/dashpay/IgnoredContactsScreen.kt | 9 +++++ .../ui/dashpay/SendDashPayPaymentSheet.kt | 34 +++++++++++----- packages/kotlin-sdk/PARITY.md | 4 +- .../dashsdk/ffi/DashpayNative.kt | 6 ++- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 36 +++++++++++++---- packages/rs-unified-sdk-jni/src/dashpay.rs | 5 ++- 14 files changed, 160 insertions(+), 60 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index e08f79a04b5..4965f0107a6 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -45,6 +45,16 @@ class AppContainer(private val context: Context) { /** Keystore-wrapped secret store (mnemonics + identity keys). */ val walletStorage = org.dashfoundation.dashsdk.security.WalletStorage(context) + /** + * Device-local DashPay contact metadata (alias / note / hidden / DPNS + * hint). A single shared instance so a write's `version` bump invalidates + * every DashPay screen that reads through it — a per-screen instance would + * only recompose its own creator. + */ + val dashPayContactMetaStore by lazy { + org.dashfoundation.example.ui.dashpay.DashPayContactMetaStore(context) + } + /** * Auth gate for secret reveals / out-of-window key access. The * container is Application-scoped, so the gate is a delegating shell; diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index ccf9e290eae..7a92748c67e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -76,13 +75,12 @@ private sealed interface SearchState { fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.current val scope = rememberCoroutineScope() val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() - val metaStore = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val identity by remember(idBytes) { container.database.identityDao().observeByIdentityId(idBytes) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt index 15b505946b0..9d51aadd27a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -37,7 +38,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -74,14 +74,13 @@ fun ContactDetailScreen( ) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.current val scope = rememberCoroutineScope() val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } val contactBytes = remember(contactIdHex) { contactIdHex.hexToBytes() } val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() - val metaStore = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val identity by remember(idBytes) { @@ -180,10 +179,12 @@ fun ContactDetailScreen( } LaunchedEffect(Unit) { refreshPayments() } - val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) - .collectAsStateWithLifecycle(false) + val syncingFlow = remember(manager) { manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false) } + val isSyncing by syncingFlow.collectAsStateWithLifecycle(false) LaunchedEffect(isSyncing) { if (!isSyncing) refreshPayments() } + var paymentSending by remember { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -332,7 +333,14 @@ fun ContactDetailScreen( } if (showPaymentSheet && manager != null && walletId != null) { - ModalBottomSheet(onDismissRequest = { showPaymentSheet = false }) { + // Block interactive dismissal (swipe / scrim / back) while a send is + // in flight, so the sheet can't be torn down mid-broadcast (the send + // itself is also NonCancellable — defense in depth). + val sheetState = rememberModalBottomSheetState(confirmValueChange = { !paymentSending }) + ModalBottomSheet( + onDismissRequest = { if (!paymentSending) showPaymentSheet = false }, + sheetState = sheetState, + ) { SendDashPayPaymentSheet( manager = manager!!, walletId = walletId!!, @@ -340,6 +348,7 @@ fun ContactDetailScreen( contactId = contactBytes, contactDisplayName = displayName, contactDpnsName = dpnsHint, + onSendingChange = { paymentSending = it }, onSent = { refreshPayments() }, onClose = { showPaymentSheet = false }, ) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index ba1f2b97434..b6057c802ed 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -33,13 +33,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -64,13 +62,12 @@ import org.dashfoundation.example.util.toHex fun ContactRequestsScreen(identityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.current val scope = rememberCoroutineScope() val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() - val metaStore = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val identity by remember(idBytes) { @@ -96,11 +93,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } - val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) - .collectAsStateWithLifecycle(false) - - // Prune the optimistic-removal overlay once the query reflects the change - // (accept promotes to established, ignore deletes the row). + // Prune the optimistic-removal overlay against the backing query: keep a + // hex only while its pair is STILL incoming-only, so an entry is dropped + // exactly when Room reflects the action's own change (accept promotes the + // pair to established → it gains an outgoing row; ignore deletes the row → + // the group disappears). This is scoped to the row change itself, so an + // unrelated sweep completing can no longer flash the row back. LaunchedEffect(rows) { val byContact = rows.groupBy { it.contactIdentityId.toHex() } removedOverlayIds = removedOverlayIds.filter { hex -> @@ -108,11 +106,6 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle group.any { !it.isOutgoing } && group.none { it.isOutgoing } }.toSet() } - // Fallback clear: after the next completed sweep, expire whatever the - // query still hasn't reflected so no row stays hidden forever. - LaunchedEffect(isSyncing) { - if (!isSyncing) removedOverlayIds = emptySet() - } fun displayNameFor(contactId: ByteArray): String = dashPayContactDisplayName( contactId = contactId, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt index 48138fc354a..3cc84a9a4f5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -62,13 +61,12 @@ import org.dashfoundation.example.util.toHex fun ContactsScreen(identityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.current val scope = rememberCoroutineScope() val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() - val metaStore = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val rows by remember(idBytes) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt index 832d6a3481b..2f81559246e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.dashpay +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -24,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -37,7 +39,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.ui.components.FormSection import org.dashfoundation.example.ui.components.LabeledContent @@ -73,6 +77,12 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController var qrUri by remember { mutableStateOf(null) } var qrError by remember { mutableStateOf(null) } + // Encode the QR off the main thread (ZXing is CPU work). + val qrBitmap by produceState(initialValue = null, qrUri) { + val uri = qrUri + value = if (uri != null) withContext(Dispatchers.Default) { generateQrBitmap(uri) } else null + } + var isEditing by remember { mutableStateOf(false) } var displayNameField by remember { mutableStateOf("") } var publicMessageField by remember { mutableStateOf("") } @@ -226,7 +236,7 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val uri = qrUri when { uri != null -> { - val bitmap = remember(uri) { generateQrBitmap(uri) } + val bitmap = qrBitmap Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 5b9040fdd86..fac43a6e24d 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.DashPayUnlockStatus import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -245,8 +246,8 @@ private fun BalanceRow( // Re-read after each completed sweep so received funds appear without a // screen reopen (the balance is an in-memory Rust snapshot pull-refresh // advances). - val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) - .collectAsStateWithLifecycle(false) + val syncingFlow = remember(manager) { manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false) } + val isSyncing by syncingFlow.collectAsStateWithLifecycle(false) LaunchedEffect(manager, walletId, identityId, isSyncing) { val m = manager receivedDuffs = if (m != null && walletId != null) { @@ -273,12 +274,20 @@ private fun UnlockBanner( managed: ManagedPlatformWallet?, onError: (String) -> Unit, ) { - val m = manager ?: return - val statusMap by m.dashPayUnlockStatus.collectAsStateWithLifecycle() - val status = walletIdHex?.let { statusMap[it] } ?: return + // Keep every composable call unconditional (collect / scope / state), then + // render conditionally on the resolved status — avoids the fragile + // early-return-before-composable-calls pattern. + val statusFlow = remember(manager) { + manager?.dashPayUnlockStatus ?: MutableStateFlow(emptyMap()) + } + val statusMap by statusFlow.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var isUnlocking by remember { mutableStateOf(false) } + + val status = walletIdHex?.let { statusMap[it] } + if (manager == null || status == null) return val hasSignal = status.draining || status.seedMismatch || status.pendingAccountBuilds > 0 if (!hasSignal) return - val scope = rememberCoroutineScope() when { status.seedMismatch -> BannerRow( @@ -300,11 +309,16 @@ private fun UnlockBanner( icon = Icons.Default.Lock, tint = MaterialTheme.colorScheme.tertiary, text = "$n contact${if (n == 1) "" else "s"} waiting to finish setup", + // Guard against a double-tap stacking concurrent unlocks: the + // SDK's `draining` flag only flips true after the verify, so a + // second tap in that window would launch a second drain. + actionEnabled = !isUnlocking, action = if (managed != null) { { + isUnlocking = true scope.launch { try { - val unlocked = m.unlockWalletFromKeystore(managed) + val unlocked = manager.unlockWalletFromKeystore(managed) if (!unlocked) { onError( "This wallet is watch-only on this device (no mnemonic in " + @@ -313,6 +327,8 @@ private fun UnlockBanner( } } catch (e: Exception) { onError(e.message ?: "Unlock failed") + } finally { + isUnlocking = false } } } @@ -325,7 +341,13 @@ private fun UnlockBanner( } @Composable -private fun BannerRow(icon: ImageVector, tint: androidx.compose.ui.graphics.Color, text: String, action: (() -> Unit)?) { +private fun BannerRow( + icon: ImageVector, + tint: androidx.compose.ui.graphics.Color, + text: String, + action: (() -> Unit)?, + actionEnabled: Boolean = true, +) { Row( modifier = Modifier.fillMaxWidth().testTag("dashpay.unlockBanner"), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -334,7 +356,7 @@ private fun BannerRow(icon: ImageVector, tint: androidx.compose.ui.graphics.Colo Icon(icon, contentDescription = null, tint = tint) Text(text, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) if (action != null) { - Button(onClick = action) { Text("Unlock") } + Button(onClick = action, enabled = actionEnabled) { Text("Unlock") } } } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt index 07cdccc5dd2..64621184feb 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt @@ -15,13 +15,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -43,13 +43,12 @@ import org.dashfoundation.example.util.toHex fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.current val scope = rememberCoroutineScope() val ownerBytes = remember(ownerIdentityIdHex) { ownerIdentityIdHex.hexToBytes() } val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() - val metaStore = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val identity by remember(ownerBytes) { @@ -70,6 +69,15 @@ fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostContr var removedOverlayIds by remember { mutableStateOf(emptySet()) } var rowErrors by remember { mutableStateOf(emptyMap()) } + // Prune the overlay once Room reflects the unhide (the rows lose their + // hidden flag): keep a hex only while its contact is still hidden, so a + // later re-hide within this screen session isn't masked. + LaunchedEffect(rows) { + val stillHidden = rows.groupBy { it.contactIdentityId.toHex() } + .filterValues { group -> group.any { it.contactHidden } }.keys + removedOverlayIds = removedOverlayIds.filter { it in stillHidden }.toSet() + } + val hiddenContacts = remember(rows, profilesByHex, removedOverlayIds, metaVersion, network) { rows.groupBy { it.contactIdentityId.toHex() } .mapNotNull { (hex, group) -> diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt index a2c9de60e4f..fa7cf1a6a1f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -76,6 +77,14 @@ fun IgnoredContactsScreen(identityIdHex: String, navController: NavHostControlle .sortedByDescending { it.ignoredAt.time } } + // Prune the overlay once Room reflects the un-ignore (the row is deleted): + // keep a hex only while its ignored row still exists, so a later re-ignore + // of the same sender within this screen session isn't masked. + LaunchedEffect(ignoredRows) { + val present = ignoredRows.mapTo(HashSet()) { it.ignoredSenderId.toHex() } + removedOverlayIds = removedOverlayIds.filter { it in present }.toSet() + } + fun unignore(senderId: ByteArray) { val w = wallet ?: return val hex = senderId.toHex() diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt index 7000629c291..b2268fce6ad 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt @@ -24,8 +24,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.wallet.PlatformWalletManager import org.dashfoundation.example.ui.theme.appStatusColors import org.dashfoundation.example.util.formatDuffs @@ -49,6 +52,7 @@ fun SendDashPayPaymentSheet( contactId: ByteArray, contactDisplayName: String, contactDpnsName: String?, + onSendingChange: (Boolean) -> Unit, onSent: () -> Unit, onClose: () -> Unit, ) { @@ -81,25 +85,37 @@ fun SendDashPayPaymentSheet( val w = wallet ?: run { errorMessage = "No wallet available for this identity"; return } val duffs = amountDuffs ?: return isSending = true + onSendingChange(true) errorMessage = null scope.launch { try { - val txid = w.dashpay.sendPayment( - fromIdentityId = senderIdentityId, - toContactIdentityId = contactId, - amountDuffs = duffs, - coreSignerHandle = manager.mnemonicResolverHandle, - memo = null, - ) - successTxidHex = txid?.let { txidDisplayHex(it) } - onSent() + // The broadcast + its post-send bookkeeping must complete even + // if the sheet is torn down mid-send: the payment leaves the + // wallet regardless (the JNI call is uncancellable), so losing + // the confirmation + durability refresh (onSent → the parent's + // refreshDashPayPayments) would invite a double-send. + withContext(NonCancellable) { + val txid = w.dashpay.sendPayment( + fromIdentityId = senderIdentityId, + toContactIdentityId = contactId, + amountDuffs = duffs, + coreSignerHandle = manager.mnemonicResolverHandle, + memo = null, + ) + successTxidHex = txid?.let { txidDisplayHex(it) } + onSent() + } + // Best-effort tail (kick a sweep + settle before auto-close). kickDashPaySync(scope, manager) delay(1500) onClose() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { errorMessage = e.message ?: "Send failed" } finally { isSending = false + onSendingChange(false) } } } diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 3bebbb4c908..022366eab4f 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -94,7 +94,7 @@ verbatim. Documented deviations from iOS are listed after the table. | ContactsView.swift | ui/dashpay/ContactsScreen.kt · `DashPayContacts` | ported — established-contacts both-direction grouping over Room `observeContactRequests`, hidden excluded, cached name/avatar via `observeContactProfiles` + the meta store, search, Hidden recovery link, pull-to-refresh | | ContactRequestsView.swift | ui/dashpay/ContactRequestsScreen.kt · `DashPayRequests` | ported — incoming (Accept via `acceptIncomingRequest` / Ignore via `ignoreContactSender`, per-row in-flight + inline error + optimistic-removal overlay) and outgoing pending; incoming avatars are never loaded (IP-leak avoidance). Adapted: the cross-screen optimistic-**sent** overlay is dropped (AddContact is a separate route, not a shared `@Binding`) | | AddContactView.swift | ui/dashpay/AddContactScreen.kt · `DashPayAddContact` | ported — 300 ms-debounced DPNS search (`searchDpnsNames`), paste-id (32-byte base58 gate), preview card, optional account label, collision dialog (Accept vs Continue anyway), and scan-to-send via `sendContactRequestFromQr` (`dashpay.addViaQR` toolbar → the shared QR scanner). Records the DPNS hint into the meta store on success | -| ContactDetailView.swift | ui/dashpay/ContactDetailScreen.kt · `DashPayContactDetail` | ported — header (cached profile + account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history (durable `refreshDashPayPayments` → `observePayments`, sorted newest-first client-side), and alias/note editors + hide toggle via `setContactInfo` **surfacing the DIP-15 deferred / watch-only publish notices** | +| ContactDetailView.swift | ui/dashpay/ContactDetailScreen.kt · `DashPayContactDetail` | ported — header (cached profile + account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history (durable `refreshDashPayPayments` → `observePayments`, sorted newest-first client-side), and alias/note editors (AlertDialogs, same `dashpay.detail.{alias,note}.{field,save,cancel}` tags as the Swift sheets) + hide toggle via `setContactInfo` **surfacing the DIP-15 deferred / watch-only publish notices** | | SendDashPayPaymentSheet.swift | ui/dashpay/SendDashPayPaymentSheet.kt | ported — hosted as a `ModalBottomSheet` from ContactDetail; amount (decimal DASH → duffs) + balance check, `sendPayment` → txid (reversed-hex via `txidDisplayHex`), then always `refreshDashPayPayments` (the durability invariant). No memo field (matches iOS — DashPay payments have no on-chain memo slot). Uses `Balance.confirmed` as spendable (Kotlin `Balance` has no `spendable`) | | DashPayProfileView.swift | ui/dashpay/DashPayProfileScreen.kt · `DashPayProfile` | ported — read-only display (avatar / name / DPNS / public message / id) + DIP-15 auto-accept QR (`buildAutoAcceptQr` rendered with the ZXing `generateQrBitmap` helper); folds in the companion editor (inline edit mode → `createOrUpdateProfile`, doCreate when no profile exists) | | IgnoredContactsView.swift | ui/dashpay/IgnoredContactsScreen.kt · `DashPayIgnored` | ported — `observeIgnoredSenders` list with Un-ignore (`unignoreContactSender`), optimistic removal + per-row in-flight/error | @@ -103,7 +103,7 @@ verbatim. Documented deviations from iOS are listed after the table. **Deviations from iOS (all intentional, K3):** -1. **Hub uses nav-section rows, not a segmented control.** The DashPay tab navigates to `DashPayContacts` / `DashPayRequests` routes instead of embedding a `[Contacts | Requests]` picker — a cleaner Compose fit. `dashpay.segment` / `dashpay.profileHeader` / `dashpay.usernamePrompt` are not reproduced; Ignored + Hidden are surfaced as hub rows (iOS gated Hidden behind a Contacts link, which the Kotlin ContactsScreen also keeps). +1. **Hub uses nav-section rows, not a segmented control.** The DashPay tab navigates to `DashPayContacts` / `DashPayRequests` routes instead of embedding a `[Contacts | Requests]` picker — a cleaner Compose fit. `dashpay.segment` / `dashpay.profileHeader` / `dashpay.profileHeader.setup` / `dashpay.usernamePrompt` are not reproduced; Ignored + Hidden are surfaced as hub rows (iOS gated Hidden behind a Contacts link, which the Kotlin ContactsScreen also keeps). 2. **Active-identity selection is in-memory** (`remember(network)`) — resets on network switch (matching iOS) but is not persisted across launches (iOS uses `@AppStorage`). 3. **Optimistic-sent overlay dropped** — AddContact is its own route, so a just-sent request appears once the post-send sync persists its outgoing row. 4. **Incoming-request avatars are always null** (IP-leak avoidance), matching iOS. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 1bc1bb1a3b7..1ac52ed291e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -179,13 +179,15 @@ internal object DashpayNative { /** * Build the owner's DIP-15 auto-accept QR payload * (`dash:?du=…&dapk=…`) for [identityId], keying the proof through - * [coreSignerHandle]. [username] is a display hint (nullable). + * [coreSignerHandle]. [username] is the owner's DPNS name and is + * **required** — the FFI rejects a null string; pass `""` for a nameless + * identity (Rust resolves the name on-chain or errors clearly). * ← Swift `ManagedPlatformWallet.buildAutoAcceptQR`. */ external fun buildAutoAcceptQr( walletHandle: Long, identityId: ByteArray, - username: String?, + username: String, coreSignerHandle: Long, ): String? diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index d140cd1c558..5bf3b9c2e23 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative +import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative /** @@ -325,10 +326,15 @@ class Dashpay internal constructor(private val walletHandle: Long) { * Build the owner's DIP-15 auto-accept QR URI for [identityId] * (`dash:?du=…&dapk=…`), keying the proof through [coreSignerHandle]. * The UI renders it as a QR (ZXing). ← Swift `buildAutoAcceptQR`. + * + * [username] is the owner's DPNS name and is **required** (matching + * Swift's `username: String`): the underlying FFI rejects a null string, + * so pass `""` for a nameless identity — Rust then resolves the name + * on-chain (or surfaces a clear "no name registered" error). */ suspend fun buildAutoAcceptQr( identityId: ByteArray, - username: String? = null, + username: String, coreSignerHandle: Long, ): String? = withContext(Dispatchers.IO) { mapNativeErrors { @@ -480,27 +486,43 @@ enum class ContactInfoPublishOutcome(val raw: Int) { /** Owned native `ContactRequest` handle from [Dashpay.sendContactRequest]. */ class ContactRequestRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) + private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef)) internal val value: Long get() = handleRef.get().also { check(it != 0L) { "ContactRequestRef has been closed" } } - /** Idempotent: the [AtomicLong] swap destroys the handle exactly once. */ + /** Idempotent: destroys the handle exactly once, on [close] or the GC backstop. */ override fun close() { - val h = handleRef.getAndSet(0) - if (h != 0L) TokensNative.contactRequestDestroy(h) + cleanable.clean() + } + + /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ + private class HandleCleanup(private val handleRef: AtomicLong) : Runnable { + override fun run() { + val h = handleRef.getAndSet(0) + if (h != 0L) TokensNative.contactRequestDestroy(h) + } } } /** Owned native `EstablishedContact` handle from [Dashpay.acceptContactRequest]. */ class EstablishedContactRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) + private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef)) internal val value: Long get() = handleRef.get().also { check(it != 0L) { "EstablishedContactRef has been closed" } } - /** Idempotent: the [AtomicLong] swap destroys the handle exactly once. */ + /** Idempotent: destroys the handle exactly once, on [close] or the GC backstop. */ override fun close() { - val h = handleRef.getAndSet(0) - if (h != 0L) TokensNative.establishedContactDestroy(h) + cleanable.clean() + } + + /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ + private class HandleCleanup(private val handleRef: AtomicLong) : Runnable { + override fun run() { + val h = handleRef.getAndSet(0) + if (h != 0L) TokensNative.establishedContactDestroy(h) + } } } diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index ce64501ca93..15d4a55a662 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -813,7 +813,10 @@ fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option Date: Tue, 7 Jul 2026 18:01:26 +0700 Subject: [PATCH 11/12] feat(sdk): DashPay migration follow-ups (seed hygiene, double-send guard, contract-ref cleaner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hardening fixes on the DashPay migration, each verified absent from the base Kotlin SDK PR before landing here: - Seed hygiene: the platform-address signing path now passes the mnemonic across JNI as a scrubbable UTF-8 byte[] (signWithMnemonicAndPathInto) the caller zeroes after use, never an un-scrubbable java.lang.String. Rust holds the phrase in a Zeroizing buffer end-to-end (no CString laundering), so the plaintext is scrubbed on drop and on any panic unwind, and is never orphaned by a realloc. - Payment double-send guard: extracted the send flow to performDashPaySend + a PaymentSender seam so the withContext(NonCancellable) guard — which keeps the broadcast and its durability bookkeeping atomic against a dispose-mid-send — is covered by a deterministic regression test. - DataContractRef: registers a NativeCleaner GC backstop like every other owned handle, so a leaked (never-closed) ref no longer leaks the native contract handle. Tests would have caught these in CI (both go red on the unfixed code): - SignerMnemonicScrubTest: 2/2 fail without the finally { fill(0) } scrub. - PerformDashPaySendDoubleSendGuardTest.disposeMidSend_stillFiresOnSent: fails (onSent count 0) without withContext(NonCancellable); happyPath is a complementary wiring pin. Verified: cargo check -p rs-unified-sdk-jni clean; :sdk + :app testDebugUnitTest green. Design and the deferred item D (durable contact-crypto queue, blocked upstream on the wallet start-state restore path) are documented under docs/dashpay/. Co-Authored-By: Claude Opus 4.8 --- .../KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md | 213 ++++++++++++++++++ docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 72 ++++++ .../ui/dashpay/SendDashPayPaymentSheet.kt | 83 +++++-- .../PerformDashPaySendDoubleSendGuardTest.kt | 89 ++++++++ .../dashsdk/ffi/SignerNative.kt | 15 +- .../dashsdk/queries/PlatformQueries.kt | 15 +- .../dashsdk/security/KeystoreSigner.kt | 44 ++-- .../security/SignerMnemonicScrubTest.kt | 54 +++++ packages/rs-unified-sdk-jni/src/signer.rs | 85 ++++--- 9 files changed, 590 insertions(+), 80 deletions(-) create mode 100644 docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md create mode 100644 docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/PerformDashPaySendDoubleSendGuardTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/SignerMnemonicScrubTest.kt diff --git a/docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md b/docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md new file mode 100644 index 00000000000..112517c5261 --- /dev/null +++ b/docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md @@ -0,0 +1,213 @@ +# Kotlin DashPay Migration — Follow-up Fixes Spec + +Scope: three DashPay follow-ups folded into the consolidated migration PR +(`feat/kotlin-sdk-dashpay-migration`, stacked on the base Kotlin SDK PR +`feat/kotlin-sdk-and-example-app`). A fourth item (durable contact-crypto +queue persistence) is **deferred** with rationale in §D. + +All three items were verified **still open at the base PR HEAD** (`1fd86fbae5`) +and **not** addressed by the parent PR — none duplicates parent work. + +--- + +## A. Platform-address signing: eliminate the JVM-String seed exposure + +### Problem +`KeystoreSigner.signPlatformAddressOnDemand` retrieves the wallet mnemonic as a +`java.lang.String` (`WalletStorage.retrieveMnemonic`) and passes it across JNI to +`SignerNative.signWithMnemonicAndPath(mnemonic: String, …)`. An immutable `String` +cannot be scrubbed; the phrase sits on the JVM heap until GC (if ever), +recoverable from a heap dump. This is the exact anti-pattern the K2 resolver path +eliminated with `resolveMnemonicInto`, and it is explicitly flagged as the tracked +follow-up in three places (`KeystoreSigner.kt:151-157`, `SignerNative.kt:36-38`, +`signer.rs:436-437`). Every on-demand platform-address signature re-exposes the seed. + +### Approach (out-buffer discipline, input direction) +Pass the phrase as a **scrubbable `ByteArray`** the caller owns and zeroes, so no +`String` of the seed ever exists on the signing path. + +- **Rust JNI** (`packages/rs-unified-sdk-jni/src/signer.rs`): replace + `Java_…_SignerNative_signWithMnemonicAndPath` with + `Java_…_SignerNative_signWithMnemonicAndPathInto`; the mnemonic argument becomes + `JByteArray`. Decode the non-secret args (path, payload) **first** and the + mnemonic **last**. **Do NOT use `convert_byte_array` + `reserve_exact`** — that + pattern orphans an unscrubbed plaintext heap copy: `convert_byte_array` returns a + `cap==len==N` `Vec`, and `CString::new`'s NUL append then forces a growth realloc + that frees the original buffer unscrubbed (real on Android's Scudo allocator). + Instead keep the phrase in a `Zeroizing>` **end-to-end** — never launder + it through a `CString` (whose `into_boxed_slice` shrink-to-fit can silently realloc + and free the plaintext unscrubbed, and which sits outside any zeroize guard across + the FFI call): + ```rust + let n = env.get_array_length(&mnemonic)? as usize; // reject Err + let mut plain: Zeroizing> = Zeroizing::new(Vec::with_capacity(n + 1)); + plain.resize(n, 0); // len=n, cap=n+1 + let dst = unsafe { slice::from_raw_parts_mut(plain.as_mut_ptr().cast::(), n) }; + env.get_byte_array_region(&mnemonic, 0, dst)?; // reject Err + if plain.contains(&0) { throw; return } // reject interior NUL + plain.push(0); // NUL-terminate in place, no realloc + // pass plain.as_ptr().cast::() to the FFI; drop(plain) scrubs the sole + // copy after signing — and on any panic unwind, since it never leaves Zeroizing. + ``` +- **Kotlin FFI decl** (`ffi/SignerNative.kt`): replace the `external fun` with + `signWithMnemonicAndPathInto(mnemonicUtf8: ByteArray, derivationPath: String, + network: Int, data: ByteArray): ByteArray?`. +- **Caller** (`security/KeystoreSigner.kt`): switch + `storage.retrieveMnemonic(...)` → `storage.retrieveMnemonicUtf8(...)` (returns + `ByteArray?`, already exists) and wrap the sign in + `try { … } finally { mnemonicUtf8.fill(0) }`. Delete the KNOWN-residual comment + block (151-157) — it is now resolved. + +The old String function has a **single caller** (verified), so it is removed +outright; no String seed path remains. The derived key still never crosses JNI — +Rust derives, signs, and scrubs internally, unchanged. + +### Failure modes +- Interior NUL in the phrase bytes → `CString::new` fails → scrub the reclaimed + `Vec`, throw (unchanged behavior). +- Empty byte array → `CString` of just NUL → the inner FFI's mnemonic parse fails → + invalid-mnemonic throw. There is **no size cap** (a large array just allocates and + then fails the parse); the caller's `retrieveMnemonicUtf8` only ever returns real + phrase bytes or null, so this is a defense-in-depth path. +- Caller forgetting to scrub → mitigated by the `finally` block; the array is + caller-owned per `retrieveMnemonicUtf8`'s contract. The Kotlin `null`-check must + precede the `try` (nothing fallible between the retrieve and entering the `try`). +- Note: the JNI symbol name (`Java_…_signWithMnemonicAndPathInto`) must match the + Kotlin `external fun` exactly — a mismatch is a runtime `UnsatisfiedLinkError`, not + a compile error, and the JVM helper test won't catch it (pinned by `cargo check` + + an instrumented/on-device sign smoke). + +### Test plan (red→green) +`KeystoreSigner` cannot be constructed on the JVM tier (its constructor eagerly +calls native `createSigner`), and `WalletStorage` is a final class with no mock +framework in the module — so a seam *on the class* is untestable. Instead extract +a **pure `internal` scrub-and-sign helper** that owns the discipline: + +```kotlin +internal inline fun signWithScrubbedMnemonic( + mnemonicUtf8: ByteArray, + derivationPath: String, + network: Int, + data: ByteArray, + sign: (ByteArray, String, Int, ByteArray) -> ByteArray?, +): ByteArray? = try { sign(mnemonicUtf8, derivationPath, network, data) } + finally { mnemonicUtf8.fill(0) } +``` + +`KeystoreSigner.signPlatformAddressOnDemand` calls it with +`sign = SignerNative::signWithMnemonicAndPathInto`. JVM test +(`SignerMnemonicScrubTest`): call the helper with a fake `sign` that records a +**copy** of the bytes it received and returns a dummy signature, then assert the +input array is all-zero afterward, and that the dummy signature is returned +(scrub happens after the sign, not before). Red→green is demonstrated by toggling +only the `finally { fill(0) }` — without it the array retains the phrase (red), +with it the array is zeroed (green). This pins the scrub invariant honestly (the +String-path removal itself is a structural guarantee, verified by the code + the +absence of any `retrieveMnemonic`/String-sign call on the signing path). +Plus `cargo check -p rs-unified-sdk-jni` for the Rust side. + +--- + +## B. Payment-sheet dispose-mid-send: pin the double-send guard + +### Problem +`SendDashPayPaymentSheet.send()` broadcasts a payment and then records the txid + +triggers the durability refresh (`onSent` → `refreshDashPayPayments`). The K3 fix +wraps the broadcast + bookkeeping in `withContext(NonCancellable)` (plus a Compose +dismissal gate in `ContactDetailScreen`) so a dispose-mid-send cannot skip the +durability refresh — which would otherwise invite a **double-send** on retry (the +JNI broadcast is uncancellable; the coin leaves the wallet regardless). This guard +has **no regression coverage**; a future refactor could drop `NonCancellable` +silently. + +### Approach (extract the send body + inject the sender) +`send()` is a local function inside a `@Composable`, uncallable from `runTest`. +**Extract the coroutine body** (`SendDashPayPaymentSheet.kt:90-120`) into a +non-composable `internal suspend fun performDashPaySend(...)` that takes a minimal +`fun interface PaymentSender { suspend fun send(...): ByteArray? }` plus the +`onSent`/`onClose`/status callbacks. The composable calls it inside its existing +`scope.launch { … }`, so runtime behavior (including the launching Job) is +identical; the production `PaymentSender` closes over `wallet`/`manager` and calls +`w.dashpay.sendPayment(...)`. `performDashPaySend` keeps the `withContext( +NonCancellable) { sender.send(...); onSent() }` wrapper — the guard under test. + +**Critical extraction constraints** (else the test is invalid): +- `performDashPaySend` is a plain `suspend fun` inheriting the caller's Job — it + must NOT introduce a new `CoroutineScope`/`coroutineScope { }`, which would + change the cancellation semantics being tested. +- The `CancellationException` rethrow stays; the best-effort tail + (`kickDashPaySync`/`delay`/`onClose`) stays OUTSIDE the `NonCancellable` block. + +### Test plan (red→green) +JVM `runTest` (`PerformDashPaySendDoubleSendGuardTest`): launch +`performDashPaySend` in a child `Job` with a fake `PaymentSender` that records the +broadcast **before** suspending on a test gate (models "broadcast completed, +bookkeeping pending" — the real hazard, not "cancelled before broadcast"): +1. launch the send; await the recorded broadcast entry; +2. cancel the child Job (simulate dispose-mid-send); +3. release the gate; join. +Assert **`onSent` fired exactly once** (count 0 pre-fix vs 1 post-fix — the +decisive assertion; the `NonCancellable` block completed despite cancellation). +Against the pre-fix code (no `NonCancellable`), cancellation observed on resume +after the broadcast skips `onSent` → count 0 → red. Assert on `onSent` count, NOT +`sendPayment` count (which is 1 either way). + +The Compose **dismissal gate** (secondary, defense-in-depth) is left to the +existing instrumented UI tier; it is not the double-send regression and is not +deterministically JVM-testable without Robolectric (not configured). + +--- + +## C. DataContractRef: add the NativeCleaner GC backstop + +### Problem +`DataContractRef` (`queries/PlatformQueries.kt:621-633`) destroys its native handle +inline in `close()` but registers **no** `NativeCleaner`, so a leaked (never-closed, +never-`use{}`) ref leaks the native contract handle forever. Every other owned +handle — `Sdk`, `ManagedPlatformWallet`, and the K3 `ContactRequestRef`/ +`EstablishedContactRef` — has the GC backstop. Consistency gap. + +### Approach +Match the established idiom exactly: `import NativeCleaner`; register +`private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef))`; +`close()` → `cleanable.clean()`; standalone inner +`private class HandleCleanup(handleRef: AtomicLong) : Runnable` whose `run()` does +`handleRef.getAndSet(0)` then `QueriesNative.dataContractDestroy(h)` iff non-zero +(destroys exactly once, whichever of close/GC fires first). `AtomicLong handleRef` +and `value` are unchanged; no call sites change. + +### Test plan +`close()` idempotency + registration are the testable surface; the native destroy +and GC-timing of the phantom backstop are not deterministically unit-testable. Rely +on parity with the already-shipped `ContactRequestRef` pattern + compile; add a +`NativeCleaner`-level idempotency assertion only if a fake action seam already +exists. (Noted as best-effort per the untestable-path carve-out.) + +--- + +## D. Durable contact-crypto queue persistence — DEFERRED (own PR) + +The DashPay deferred contact-crypto queue (`PlatformWalletChangeSet. +pending_contact_crypto_added/_cleared`) is not durable across process death on FFI +hosts. Research (see below) shows only the **write** half is cleanly addable; the +**restore** half is blocked upstream: + +- The in-repo SQLite persister already writes the queue, but its reader is + `#[cfg(test)]`-only "because production load restore is blocked upstream + (`LOAD_UNIMPLEMENTED: ClientStartState::wallets`)". +- `rs-platform-wallet/src/wallet/apply.rs:111-116` drops the queue fields on the + changeset-replay path; restore is meant to flow through the wallet **start-state** + path, which isn't wired for this field. + +Adding the FFI `on_persist_pending_contact_crypto_fn` slot + JNI trampoline + a +`DashDatabase` v3→v4 Room migration + Swift SwiftData model would persist rows that +**nothing reads back** — a large, irreversible, cross-cutting surface (including a +schema migration) for **zero cross-restart durability** until the upstream +start-state restore exists. This is worse than the current honest deferral: the +recurring signerless sweep already re-enqueues the work after restart, so the only +exposure is delayed (not lost) contact-crypto work between sweeps. + +**Decision:** keep as a documented leftover; implement as a dedicated PR once the +`rs-platform-wallet` start-state restore path lands. Full data-path map (producers, +SQLite schema, FFI/JNI/Kotlin/Swift layers, the add-a-persisted-field recipe) is +recorded for that future PR. diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md new file mode 100644 index 00000000000..c78bfa1e92a --- /dev/null +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -0,0 +1,72 @@ +# Kotlin DashPay Migration — Known Leftovers & Follow-ups + +Durable record of what is and isn't done as of the consolidated DashPay migration +PR (K1 + K2 + K3 + the follow-up fixes below). Kept so nothing is silently lost +when the four stacked PRs collapse into one. + +## Resolved in this PR (follow-up fixes) + +- **Seed hygiene on the platform-address signing path** — the mnemonic now crosses + JNI as a scrubbable UTF-8 `byte[]` (`signWithMnemonicAndPathInto`) that the caller + zeroes after use, never an un-scrubbable `java.lang.String`. Rust reads it into a + pre-sized buffer so the `CString` conversion cannot orphan an unscrubbed plaintext + copy. Pins: `SignerMnemonicScrubTest` (JVM, red→green). +- **Payment dispose-mid-send double-send guard** — the send flow was extracted to + `performDashPaySend` + a `PaymentSender` seam so the `withContext(NonCancellable)` + guard (broadcast + durability bookkeeping stay atomic against a mid-send teardown) + has a deterministic regression test: `PerformDashPaySendDoubleSendGuardTest` (JVM, + red→green). +- **`DataContractRef` GC backstop** — now registers a `NativeCleaner` like every + other owned handle, so a leaked (never-closed) ref no longer leaks the native + contract handle. + +## Deferred to dedicated follow-up PRs + +- **Durable contact-crypto queue persistence (item D).** The DashPay deferred + contact-crypto queue (`PlatformWalletChangeSet.pending_contact_crypto_added/ + _cleared`) is not durable across process death on FFI hosts. Only the *write* half + is cleanly addable; the *restore* half is blocked upstream — the in-repo SQLite + persister's reader is `#[cfg(test)]`-only "because production load restore is + blocked upstream (`LOAD_UNIMPLEMENTED: ClientStartState::wallets`)", and + `rs-platform-wallet/src/wallet/apply.rs` drops the queue fields on the + changeset-replay path. Adding the FFI slot + JNI trampoline + a `DashDatabase` + v3→v4 Room migration + Swift model would persist rows nothing reads back — a large, + irreversible surface (incl. a schema migration) for zero durability until the + upstream start-state restore lands. The recurring signerless sweep already + re-enqueues the work after restart, so the exposure is *delayed*, not *lost* + contact-crypto work between sweeps. Do it as its own PR once the start-state + restore exists. Full data-path map + add-a-persisted-field recipe recorded during + the follow-ups research. + +- **`signWithMnemonicAndPathInto` instrumented sign smoke.** The JVM test pins the + seed-scrub invariant but NOT the JNI symbol binding: a Rust↔Kotlin symbol-name + mismatch is a runtime `UnsatisfiedLinkError`, not a compile error, and the JVM + helper test never loads the native symbol. `cargo check` + an emulator/on-device + platform-address signature are what pin the actual native call. + +- **PARITY: 8 partial / 7 deferred Swift views.** Each partial/deferred row in + `packages/kotlin-sdk/PARITY.md` names the exact missing FFI export it needs; those + land as the corresponding exports are added. + +## Environment-bound (cannot be code-fixed here) + +- **End-to-end send→accept→pay testnet UAT** — device/testnet-bound; not runnable in + CI. Must be exercised on a real testnet wallet before relying on the full DashPay + flow. +- **Live-network paths** (`searchDpnsNames`, `dashPaySyncNow`, the DashPay write + paths) are exercised only under the `-Ptestnet=true` instrumented tier. + +## Behavioral notes to carry (from the base Kotlin SDK PR) + +- `rs-sdk-ffi` / `rs-sdk-trusted-context-provider` use rustls + webpki roots instead + of the platform TLS stack (OpenSSL doesn't exist on Android). This also changes the + iOS trust roots — no API change, but worth an iOS-side look. + +## Review findings — all resolved upstream + +Every P0/P1/P2 from the base-PR reviews (invalid Cargo `--features` arg, JNI +local-frame leaks, negative-amount/index/selector validation across credits/tokens/ +funding/identity, `sendDashPayPayment` guard) was verified **already fixed** at the +base PR HEAD before this consolidation — none was outstanding. The only carried +review finding was the contact-crypto durability suggestion, addressed as item D +above. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt index b2268fce6ad..ca645fc43a1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt @@ -88,35 +88,30 @@ fun SendDashPayPaymentSheet( onSendingChange(true) errorMessage = null scope.launch { - try { - // The broadcast + its post-send bookkeeping must complete even - // if the sheet is torn down mid-send: the payment leaves the - // wallet regardless (the JNI call is uncancellable), so losing - // the confirmation + durability refresh (onSent → the parent's - // refreshDashPayPayments) would invite a double-send. - withContext(NonCancellable) { - val txid = w.dashpay.sendPayment( + performDashPaySend( + sender = { + w.dashpay.sendPayment( fromIdentityId = senderIdentityId, toContactIdentityId = contactId, amountDuffs = duffs, coreSignerHandle = manager.mnemonicResolverHandle, memo = null, ) - successTxidHex = txid?.let { txidDisplayHex(it) } - onSent() - } - // Best-effort tail (kick a sweep + settle before auto-close). - kickDashPaySync(scope, manager) - delay(1500) - onClose() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - errorMessage = e.message ?: "Send failed" - } finally { - isSending = false - onSendingChange(false) - } + }, + onSuccessTxid = { successTxidHex = it }, + onError = { errorMessage = it }, + onSent = onSent, + settle = { + // Best-effort tail (kick a sweep + settle before auto-close). + kickDashPaySync(scope, manager) + delay(1500) + }, + onClose = onClose, + onSendingDone = { + isSending = false + onSendingChange(false) + }, + ) } } @@ -217,3 +212,45 @@ fun SendDashPayPaymentSheet( } } } + +/** Broadcasts a DashPay payment; the sole suspend seam so [performDashPaySend] is JVM-testable. */ +internal fun interface PaymentSender { + suspend fun send(): ByteArray? +} + +/** + * The payment send flow, extracted from [SendDashPayPaymentSheet] so the + * dispose-mid-send double-send guard is unit-testable. Runs in the CALLER's Job + * (a plain `suspend fun`, no new scope — so cancellation semantics are the ones + * under test): the broadcast + its durability bookkeeping ([onSuccessTxid] then + * [onSent]) run inside [NonCancellable], so a teardown that cancels mid-send + * cannot skip [onSent]. Losing [onSent] after the coin has left the wallet (the + * JNI broadcast is uncancellable) would invite a double-send on retry. The + * best-effort tail ([settle] then [onClose]) stays cancellable, and a + * [CancellationException] still propagates so structured concurrency is intact. + */ +internal suspend fun performDashPaySend( + sender: PaymentSender, + onSuccessTxid: (String?) -> Unit, + onError: (String) -> Unit, + onSent: () -> Unit, + settle: suspend () -> Unit, + onClose: () -> Unit, + onSendingDone: () -> Unit, +) { + try { + withContext(NonCancellable) { + val txid = sender.send() + onSuccessTxid(txid?.let { txidDisplayHex(it) }) + onSent() + } + settle() + onClose() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onError(e.message ?: "Send failed") + } finally { + onSendingDone() + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/PerformDashPaySendDoubleSendGuardTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/PerformDashPaySendDoubleSendGuardTest.kt new file mode 100644 index 00000000000..fe7935d2ffa --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/PerformDashPaySendDoubleSendGuardTest.kt @@ -0,0 +1,89 @@ +package org.dashfoundation.example.ui.dashpay + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression test for the DashPay payment dispose-mid-send double-send guard + * ([performDashPaySend]'s `withContext(NonCancellable)` block). + * + * The hazard: the JNI broadcast is uncancellable, so once `sendPayment` + * returns the coin has left the wallet. If the coroutine is cancelled between + * the broadcast and the durability bookkeeping (`onSent` → + * `refreshDashPayPayments`), the app never records the payment and a retry + * double-sends. The fake [PaymentSender] records the broadcast BEFORE + * suspending on a gate, modelling exactly "broadcast completed, bookkeeping + * still pending" — then the test cancels the hosting Job before releasing it. + * + * Red→green: remove the `withContext(NonCancellable)` wrapper and + * [disposeMidSend_stillFiresOnSent] fails — the cancellation observed on resume + * skips `onSent`, so its count is 0 instead of 1. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PerformDashPaySendDoubleSendGuardTest { + + @Test + fun disposeMidSend_stillFiresOnSent() = runTest { + val broadcastEntered = CompletableDeferred() + val releaseBroadcast = CompletableDeferred() + var sendCount = 0 + var onSentCount = 0 + + val sender = PaymentSender { + sendCount++ + broadcastEntered.complete(Unit) // record the broadcast BEFORE the gate + releaseBroadcast.await() // suspension window: bookkeeping still pending + ByteArray(32) { 0x11 } // txid — the coin has left the wallet + } + + val job = launch { + performDashPaySend( + sender = sender, + onSuccessTxid = {}, + onError = {}, + onSent = { onSentCount++ }, + settle = {}, + onClose = {}, + onSendingDone = {}, + ) + } + + broadcastEntered.await() // broadcast has happened + job.cancel() // dispose the sheet mid-send + releaseBroadcast.complete(Unit) + job.join() + + assertEquals("broadcast must run exactly once", 1, sendCount) + assertEquals( + "onSent (durability refresh) must still fire despite dispose-mid-send", + 1, + onSentCount, + ) + } + + @Test + fun happyPath_firesOnSentAndCloses() = runTest { + var onSentCount = 0 + var closed = false + var doneCount = 0 + + performDashPaySend( + sender = { ByteArray(32) { 0x22 } }, + onSuccessTxid = {}, + onError = {}, + onSent = { onSentCount++ }, + settle = {}, + onClose = { closed = true }, + onSendingDone = { doneCount++ }, + ) + + assertEquals(1, onSentCount) + assertTrue("sheet auto-closes after a successful send", closed) + assertEquals("sending state is cleared exactly once", 1, doneCount) + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt index 74b1c749858..a39a2d307b8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt @@ -29,17 +29,20 @@ internal object SignerNative { /** * One-shot derive-then-sign for platform-address keys (the * `keyType == 0xFF` branch). Derives an ECDSA secp256k1 key from - * `(mnemonic, derivationPath)`, signs [data], returns the signature — + * `(mnemonicUtf8, derivationPath)`, signs [data], returns the signature — * entirely inside Rust (`dash_sdk_sign_with_mnemonic_and_path`). The * derived key never crosses JNI; the seed + scalar are held in * Rust-owned zeroizing buffers. Mirrors - * `KeychainSigner.signPlatformAddressOnDemand` on iOS. Caller zeroes - * nothing itself — the mnemonic `String` is Kotlin-owned; wipe it if a - * `CharArray` is used upstream. + * `KeychainSigner.signPlatformAddressOnDemand` on iOS. + * + * [mnemonicUtf8] is the raw UTF-8 phrase bytes (never a `java.lang.String`, + * which cannot be scrubbed). The caller OWNS the array and MUST `fill(0)` + * it after the call; Rust copies it into a scrubbed buffer and holds no + * other copy. * @throws DashSDKException on any derivation/signing failure */ - external fun signWithMnemonicAndPath( - mnemonic: String, + external fun signWithMnemonicAndPathInto( + mnemonicUtf8: ByteArray, derivationPath: String, network: Int, data: ByteArray, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index 207fd4827a8..22006e36ae4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.Sdk import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.QueriesNative /** @@ -621,14 +622,22 @@ data class ContractWithSerialization( /** Owned native data-contract handle. */ class DataContractRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) + private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef)) internal val value: Long get() = handleRef.get().also { check(it != 0L) { "DataContractRef has been closed" } } - /** Idempotent: the [AtomicLong] swap destroys the handle exactly once. */ + /** Idempotent: destroys the handle exactly once, on [close] or the GC backstop. */ override fun close() { - val h = handleRef.getAndSet(0) - if (h != 0L) QueriesNative.dataContractDestroy(h) + cleanable.clean() + } + + /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ + private class HandleCleanup(private val handleRef: AtomicLong) : Runnable { + override fun run() { + val h = handleRef.getAndSet(0) + if (h != 0L) QueriesNative.dataContractDestroy(h) + } } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 71332a124f6..7fef99c9eec 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt @@ -34,7 +34,7 @@ import org.dashfoundation.dashsdk.persistence.dao.PlatformAddressDao * derivationPath)` — the `derivationPath` + `walletId` are resolved from the * `PlatformAddressEntity` row via [platformAddressDao], the mnemonic from * [storage], and the derive-and-sign happens entirely inside Rust - * ([SignerNative.signWithMnemonicAndPath]). Direct port of + * ([SignerNative.signWithMnemonicAndPathInto]). Direct port of * `KeychainSigner.signPlatformAddressOnDemand` on iOS. */ class KeystoreSigner( @@ -148,15 +148,11 @@ class KeystoreSigner( ) return } - // KNOWN residual seed exposure: this materializes the phrase as an - // un-scrubbable JVM String and passes it across JNI — the exact - // anti-pattern the resolver path eliminated with - // resolveMnemonicInto. Migrating this call to an out-buffer - // signWithMnemonicAndPathInto variant is the tracked follow-up; - // until then every on-demand platform-address signature re-exposes - // the phrase on the JVM heap. - val mnemonic = storage.retrieveMnemonic(row.walletId) - if (mnemonic == null) { + // The phrase crosses JNI as raw UTF-8 bytes the caller scrubs after the + // call — never an un-scrubbable JVM String (the resolveMnemonicInto + // discipline, applied here to the signing path). + val mnemonicUtf8 = storage.retrieveMnemonicUtf8(row.walletId) + if (mnemonicUtf8 == null) { SignerNative.completeSign( completionToken, null, @@ -164,12 +160,14 @@ class KeystoreSigner( ) return } - val signature = SignerNative.signWithMnemonicAndPath( - mnemonic, + val signature = signWithScrubbedMnemonic( + mnemonicUtf8, row.derivationPath, network.ffiValue, data, - ) + ) { m, path, net, payload -> + SignerNative.signWithMnemonicAndPathInto(m, path, net, payload) + } if (signature != null) { SignerNative.completeSign(completionToken, signature, null) } else { @@ -232,3 +230,23 @@ class KeystoreSigner( const val PLATFORM_ADDRESS_HASH_KEY_TYPE: Int = 0xFF } } + +/** + * Runs [sign] with the caller-owned mnemonic bytes and scrubs them on every + * exit path (success, null, or throw). The phrase is passed as a [ByteArray] + * precisely so it can be zeroed — this helper owns that discipline, so the + * platform-address signing path never leaves the plaintext on the JVM heap + * past the call. [sign] must consume the bytes synchronously (it does: the JNI + * call copies them into a Rust-owned scrubbed buffer before returning). + */ +internal fun signWithScrubbedMnemonic( + mnemonicUtf8: ByteArray, + derivationPath: String, + network: Int, + data: ByteArray, + sign: (ByteArray, String, Int, ByteArray) -> ByteArray?, +): ByteArray? = try { + sign(mnemonicUtf8, derivationPath, network, data) +} finally { + mnemonicUtf8.fill(0) +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/SignerMnemonicScrubTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/SignerMnemonicScrubTest.kt new file mode 100644 index 00000000000..8dfda83246e --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/SignerMnemonicScrubTest.kt @@ -0,0 +1,54 @@ +package org.dashfoundation.dashsdk.security + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.fail +import org.junit.Test + +/** + * Pins the seed-hygiene invariant of [signWithScrubbedMnemonic]: the platform- + * address signing path passes the mnemonic as a caller-owned [ByteArray] and + * zeroes it after use, so the plaintext never lingers on the JVM heap past the + * call (the un-scrubbable `String` path it replaces could not do this). + * + * Red→green: with the helper's `finally { fill(0) }` removed, the phrase buffer + * survives the call and [scrubsMnemonicBytesAfterSigning] / + * [scrubsMnemonicBytesEvenWhenSignThrows] both fail. + */ +class SignerMnemonicScrubTest { + + @Test + fun scrubsMnemonicBytesAfterSigning() { + val phrase = "abandon abandon abandon about".toByteArray(Charsets.UTF_8) + val original = phrase.copyOf() + var seenAtSignTime: ByteArray? = null + + val signature = signWithScrubbedMnemonic( + mnemonicUtf8 = phrase, + derivationPath = "m/9'/5'/3'/0/0", + network = 1, + data = byteArrayOf(1, 2, 3), + ) { m, _, _, _ -> + // The signer sees the real phrase bytes (scrub happens AFTER, not before). + seenAtSignTime = m.copyOf() + byteArrayOf(0xAB.toByte(), 0xCD.toByte()) + } + + assertArrayEquals("signer must receive the intact phrase", original, seenAtSignTime) + assertArrayEquals("signature is returned unchanged", byteArrayOf(0xAB.toByte(), 0xCD.toByte()), signature) + assertArrayEquals("caller buffer must be zeroed after the call", ByteArray(phrase.size), phrase) + } + + @Test + fun scrubsMnemonicBytesEvenWhenSignThrows() { + val phrase = "zoo zoo zoo wrong".toByteArray(Charsets.UTF_8) + try { + signWithScrubbedMnemonic(phrase, "m/9'", 1, byteArrayOf()) { _, _, _, _ -> + throw RuntimeException("derive-and-sign failed") + } + fail("expected the sign failure to propagate") + } catch (_: RuntimeException) { + // expected + } + assertArrayEquals("caller buffer must be zeroed even when signing throws", ByteArray(phrase.size), phrase) + } +} diff --git a/packages/rs-unified-sdk-jni/src/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index 22361a31e3b..a6e3f9f7367 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -30,14 +30,6 @@ use std::ffi::{c_void, CString}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -/// Zero a [`CString`]'s backing buffer before releasing it. For buffers -/// holding key material (the mnemonic) — a plain drop would return the -/// plaintext bytes to the allocator intact. -fn scrub_cstring(c: CString) { - let mut bytes = c.into_bytes(); - bytes.iter_mut().for_each(|b| *b = 0); -} - struct KotlinSignerCtx { bridge: GlobalRef, } @@ -343,14 +335,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith /// /// Caller (`KeystoreSigner`) is responsible for pulling the path off the /// `PlatformAddressEntity.derivationPath` row and the mnemonic off -/// `WalletStorage.retrieveMnemonic(walletId)` per signing call, exactly as +/// `WalletStorage.retrieveMnemonicUtf8(walletId)` per signing call, exactly as /// the Swift caller does. Throws `DashSDKException` on any derivation / /// signing failure. +/// +/// The mnemonic crosses JNI as raw UTF-8 `byte[]` (never a `java.lang.String`, +/// which cannot be scrubbed): the caller owns and zeroes the array after the +/// call, and Rust holds the only remaining copy in a scrubbed `CString`. #[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWithMnemonicAndPath( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWithMnemonicAndPathInto( mut env: JNIEnv, _class: JClass, - mnemonic: JString, + mnemonic_utf8: JByteArray, derivation_path: JString, network: jint, data: JByteArray, @@ -377,28 +373,47 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith ); return ptr::null_mut(); }; - let Ok(mnemonic_str) = env.get_string(&mnemonic).map(String::from) else { - let _ = env.exception_clear(); - crate::support::throw_sdk_exception(env, 1, "invalid mnemonic argument"); - return ptr::null_mut(); - }; - // The mnemonic buffer is scrubbed on every path. `reserve_exact(1)` - // BEFORE `CString::new` matters: the NUL append would otherwise - // reallocate a capacity==len buffer, freeing the original plaintext - // allocation unscrubbed — the scrub would then only touch the copy. - let mut mnemonic_bytes = mnemonic_str.into_bytes(); - mnemonic_bytes.reserve_exact(1); - let mnemonic_c = match CString::new(mnemonic_bytes) { - Ok(c) => c, - Err(e) => { - // NulError hands back the original allocation via into_vec. - let mut leaked = e.into_vec(); - leaked.iter_mut().for_each(|b| *b = 0); - crate::support::throw_sdk_exception(env, 1, "mnemonic contained an interior NUL"); + // Read the phrase into a `Zeroizing` buffer sized to N+1 and keep the + // plaintext inside it end-to-end: reject interior NULs, NUL-terminate in + // place (the spare slot means `push` never reallocates), and hand the + // FFI a pointer straight into this buffer. Routing the secret through a + // `CString` instead would move it into a plain (non-`Zeroizing`) + // allocation whose `into_boxed_slice` shrink-to-fit can silently realloc + // and free the plaintext UNSCRUBBED; keeping it in the `Zeroizing` + // buffer means it is scrubbed on drop AND on any panic unwind, across + // the FFI call. (`convert_byte_array` is avoided for the same reason — + // its capacity==len buffer would force such a realloc.) + let n = match env.get_array_length(&mnemonic_utf8) { + Ok(len) if len >= 0 => len as usize, + _ => { + let _ = env.exception_clear(); + crate::support::throw_sdk_exception(env, 1, "invalid mnemonic argument"); return ptr::null_mut(); } }; + let mut mnemonic_bytes: zeroize::Zeroizing> = + zeroize::Zeroizing::new(Vec::with_capacity(n + 1)); + mnemonic_bytes.resize(n, 0); + { + // `get_byte_array_region` copies straight into Rust memory (no + // pinned JVM buffer). jbyte is i8; the Vec is u8 — same layout. + let dst = + unsafe { std::slice::from_raw_parts_mut(mnemonic_bytes.as_mut_ptr().cast::(), n) }; + if env.get_byte_array_region(&mnemonic_utf8, 0, dst).is_err() { + let _ = env.exception_clear(); + crate::support::throw_sdk_exception(env, 1, "invalid mnemonic argument"); + return ptr::null_mut(); + } + } + // An interior NUL would truncate the phrase at the C boundary; reject it + // rather than sign under a partial phrase. Then NUL-terminate in place — + // capacity is already N+1, so this `push` cannot reallocate. + if mnemonic_bytes.contains(&0) { + crate::support::throw_sdk_exception(env, 1, "mnemonic contained an interior NUL"); + return ptr::null_mut(); + } + mnemonic_bytes.push(0); let network = match network { 0 => dash_network::ffi::FFINetwork::Mainnet, @@ -417,7 +432,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith let rc = unsafe { dash_sdk_sign_with_mnemonic_and_path( - mnemonic_c.as_ptr(), + mnemonic_bytes.as_ptr().cast::(), ptr::null(), // empty passphrase path_c.as_ptr(), payload.as_ptr(), @@ -431,11 +446,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith ) }; - // The actual secret this function holds is the mnemonic, not the - // signature — scrub it before either return path. (The JVM-side - // String the caller passed remains the pre-existing exposure; - // tracked as the KeystoreSigner follow-up.) - scrub_cstring(mnemonic_c); + // The secret this function holds is the mnemonic; scrub it the instant + // signing is done — dropping the `Zeroizing` buffer zeroes the sole + // Rust-side copy. The caller's `byte[]` is scrubbed Kotlin-side; no + // un-scrubbable `String` of the phrase exists. + drop(mnemonic_bytes); if rc != 0 || err_tag != SIGN_WITH_MNEMONIC_OK { crate::support::throw_sdk_exception( From 19ca1a52d5632bdbeaa0cad8d90075073d29d30d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 7 Jul 2026 19:02:52 +0700 Subject: [PATCH 12/12] docs(sdk): port DashPay test cases (DP-01..11) to the Kotlin TEST_PLAN + fix PARITY count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kotlin TEST_PLAN §4.10 DashPay section was generated before the K3 DashPay-tab port and had gone stale: only DP-01..06 + DP-11, still pointing at the retired FriendsScreen/IdentityDetailScreen, DP-06 mislabelled "Reject" (now Ignore), and missing DP-07/08/09/10. Ported the full DP-01..11 from the iOS TEST_PLAN, adapted to the Android app — real ui/dashpay/*Screen.kt entry points, verified dashpay.* testTags, Kotlin bridge fns, and the K3 adaptations (nav-section rows, own AddContact route, ContactInfoPublishOutcome). Updated the category index (DP-01..11), the regression tag (+DP-10), and the Platform/Cross counts. Also corrected KOTLIN_MIGRATION_LEFTOVERS.md: PARITY is 97 ported / 2 partial / 0 deferred (neither partial is DashPay), not the stale "8 partial / 7 deferred" copied from an older PR body. Docs only — no code/behavior change. Co-Authored-By: Claude Opus 4.8 --- docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 9 ++++--- .../kotlin-sdk/KotlinExampleApp/TEST_PLAN.md | 26 +++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index c78bfa1e92a..aa606202eef 100644 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -44,9 +44,12 @@ when the four stacked PRs collapse into one. helper test never loads the native symbol. `cargo check` + an emulator/on-device platform-address signature are what pin the actual native call. -- **PARITY: 8 partial / 7 deferred Swift views.** Each partial/deferred row in - `packages/kotlin-sdk/PARITY.md` names the exact missing FFI export it needs; those - land as the corresponding exports are added. +- **PARITY: 97 ported / 2 partial / 0 deferred views** (`packages/kotlin-sdk/PARITY.md`). + Neither partial is DashPay — all 10 DashPay screens are fully ported. The two are + `TransitionDetailView` (5 of 23 transition-catalog forms lack backing FFIs: + dataContractUpdate, documentCreate/Replace/Delete/Transfer) and + `WalletMemoryExplorerView` (asset-lock drill-down summary only). Each partial row + names the exact missing FFI export; they land as those exports are added. ## Environment-bound (cannot be code-fixed here) diff --git a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md index 80d0301b4a0..f28a65e6078 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md +++ b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md @@ -256,13 +256,17 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | |---|---|---|---|---|---|---| -| DP-01 | Send contact request | Platform | Common | ✅ | | `FriendsScreen` (AddFriend) → `platform_wallet_send_contact_request_with_signer`. | -| DP-02 | Accept contact request | Platform | Common | ✅ | | `FriendsScreen` → `platform_wallet_accept_contact_request_with_signer`. | -| DP-03 | Send DashPay payment to a contact | Platform | Common | ✅ | | `FriendsScreen` → `platform_wallet_send_dashpay_payment`. | -| DP-04 | Create / update DashPay profile | Platform | Common | ✅ | | `IdentityDetailScreen` profile editor → `platform_wallet_create_or_update_dashpay_profile_with_signer`. | -| DP-05 | View profile / contacts / requests | Platform | Common | ✅ | | `FriendsScreen`, `EstablishedContactEntity` (Room). | -| DP-06 | Reject contact request | Platform | Thorough | ✅ | | `FriendsScreen` → `wallet.rejectContactRequest`. | -| DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A sends contact request (`DP-01`) to B; switch to wallet B and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop. | +| DP-01 | Send contact request | Platform | Common | ✅ | | DashPay tab → **Add Contact** (`dashpay.addContact` → `ui/dashpay/AddContactScreen.kt` · `DashPayAddContact`): by Identity ID (`dashpay.addContact.idInput`, base58 32-byte gated) or DPNS username (`dashpay.addContact.input`, ≥2-char debounced `searchDpnsNames`; not-found → `dashpay.addContact.retry`; collision dialog) → **Send** (`dashpay.addContact.send`) → `sendContactRequest` (`platform_wallet_send_contact_request_with_signer`). Its own route (no cross-screen optimistic overlay); the outgoing pending row appears once post-send sync persists it. | +| DP-02 | Accept contact request | Platform | Common | ✅ | | `ui/dashpay/ContactRequestsScreen.kt` · `DashPayRequests` (Incoming) → **Accept** (`dashpay.request.accept`) → `acceptIncomingRequest` (`platform_wallet_accept_contact_request_with_signer`). Contact moves to `ContactsScreen`; the bidirectional contact persists (both request-direction rows present for the pair). | +| DP-03 | Send DashPay payment to a contact | Platform | Common | ✅ | | `ui/dashpay/ContactDetailScreen.kt` → **Send Dash** (`dashpay.detail.sendDash`) → `SendDashPayPaymentSheet` (`dashpay.send.amount` → `dashpay.send.confirm`) → `sendPayment` (`platform_wallet_send_dashpay_payment`), then always `refreshDashPayPayments` (the durability invariant; manual refresh `dashpay.detail.refreshPayments`). A DashPay payment is an **L1 transaction**, so it requires the **Core SPV client running** (`CORE-07`) — with SPV stopped the broadcast fails. Payment appears in the contact's Room-backed history (txid via `txidDisplayHex`). **Verify both directions** — the channel is symmetric once established (A→B *and* B→A); each sender needs its own funded Core wallet + running SPV, both endpoints on the **same network**. Send is disabled while the payment channel is broken. | +| DP-04 | Create / update DashPay profile | Platform | Common | ✅ | | `ui/dashpay/DashPayProfileScreen.kt` · `DashPayProfile` → **Edit** (`dashpay.profile.edit`) → inline editor → **Done** (`dashpay.profile.done`) → `createOrUpdateProfile` (`platform_wallet_create_or_update_dashpay_profile_with_signer`), doCreate when no profile exists. Non-destructive update; avatar renders via the `DashPayAvatar` (Coil) composable off the re-fetched profile. | +| DP-05 | View profile / contacts / requests | Platform | Common | ✅ | read-only | DashPay tab (`dashpay.tab`): `ContactsScreen` (`dashpay.openContacts`, established + search `dashpay.search`), `ContactRequestsScreen` (`dashpay.openRequests`, incoming/outgoing), `ContactDetailScreen`, `DashPayProfileScreen` (`dashpay.openProfile`) — backed by Room `observeContactRequests`; established contacts are derived in-memory by joining each pair's incoming + outgoing request rows. Received-from-contacts balance at `dashpay.receivedBalance`. | +| DP-06 | Ignore a contact request (reversible local mute) | Platform | Thorough | ✅ | | `ContactRequestsScreen` → **Ignore** (`dashpay.request.ignore`) → `ignoreContactSender`. The sender leaves the requests list and appears in `ui/dashpay/IgnoredContactsScreen.kt` · `DashPayIgnored` (`dashpay.openIgnored`); **Un-ignore** there → `unignoreContactSender` reverses it. Local-only, no on-chain artifact (R1 privacy); persists across relaunch. | +| DP-07 | Attach `encryptedAccountLabel`; see contact's "Their account" on receive | Platform | Common | ✅ | | DIP-15 §8.5. Send: `AddContactScreen` → **Account label** (`dashpay.addContact.accountLabel`) carried into `sendContactRequest(accountLabel = …)`. Receive: the counterparty's `ContactDetailScreen` shows a read-only **"Their account"** block (assert on visible text — no testTag yet). Verify on a two-wallet loop (cf. `DP-11`): the ingested request carries the encrypted bytes, but the plaintext is decrypted **on accept** (the signer-bearing register step) and shown on the **incoming row only** (direction-specific). | +| DP-08 | QR auto-accept (build "Add me" QR + add via scanned URI) | Platform | Thorough | ✅ | | DIP-15 §8.13. Build: `DashPayProfileScreen` → **Add me (DIP-15 QR)** (`dashpay.profile.qrURI`; `du=…&dapk=…`, 1h validity `AUTO_ACCEPT_TTL_SECS=3600`) via `buildAutoAcceptQr`, rendered with the ZXing `generateQrBitmap` helper. Add: DashPay tab → **Add via QR** (`dashpay.addViaQR`) → the shared QR scanner → `sendContactRequestFromQr`. Two-wallet: A builds the QR, B scans it → the request is auto-accepted by A without A manually accepting (a distinct path from `DP-02`). **A's reciprocal is signer-backed**, so it only lands once A's wallet is **unlocked** (the deferred contact-crypto drain); the request + auto-accept proof reach A immediately, the reciprocal after unlock. A camera scan on a physical device is the Manual variant. | +| 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. | ### 4.11 System / Protocol / Diagnostics — `Domain=System` @@ -298,8 +302,8 @@ Counts are of rows reachable in the app (Status `✅`/`🧪`/`⚠️`); `🔌`/` | Layer | Count (approx.) | |---|---| | Core | 17 | -| Platform | ~71 | -| Cross | 6 | +| Platform | ~74 | +| Cross | 7 | | Shielded | 16 | --- @@ -317,7 +321,7 @@ Membership of each feature category across **all** sections (primary section mem - **Document** — `DOC-01..15` - **Token** — `TOK-01..17` - **Shielded** — `SH-01..16`, `CORE-21` -- **DashPay** — `DP-01..06`, `DP-11` +- **DashPay** — `DP-01..11` - **System / Diagnostics** — `SYS-01..08` ### Tag index @@ -328,6 +332,6 @@ Tags are cross-cutting modalities. A test may appear under multiple tags. - **group** — `TOK-15`, `TOK-16` - **contested** — `DPNS-05`, `DPNS-08`, `VOTE-01..06` - **withdrawal** — `ID-10`, `ADDR-04`, `SH-08`, `SH-16` -- **regression** — `TOK-17` +- **regression** — `TOK-17`, `DP-10` - **read-only** — `SH-13`, `SYS-05`, `SYS-06`, `VOTE-02..06` - **masternode** — `VOTE-01`