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..aa606202eef --- /dev/null +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -0,0 +1,75 @@ +# 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: 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) + +- **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/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/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` 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/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/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index ef8895f8e52..de268a85f65 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 @@ -47,6 +47,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; @@ -185,6 +195,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) } @@ -216,6 +227,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..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,8 +29,15 @@ 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.FriendsScreen import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen import org.dashfoundation.example.ui.identity.IdentityDetailScreen import org.dashfoundation.example.ui.identity.KeyDetailScreen @@ -166,11 +173,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 +382,39 @@ fun AppNavHost( CoSignProposalScreen(entry.toRoute(), navController) } + // ── DashPay graph ────────────────────────────────────────────── + + 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 f9627744f9c..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 @@ -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) @@ -177,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`). */ @@ -356,6 +381,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/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt new file mode 100644 index 00000000000..7a92748c67e --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -0,0 +1,529 @@ +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.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 scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + + 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..9d51aadd27a --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt @@ -0,0 +1,467 @@ +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.rememberModalBottomSheetState +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.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 — 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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactDetailScreen( + identityIdHex: String, + contactIdHex: String, + navController: NavHostController, +) { + val container = LocalAppContainer.current + val appState = LocalAppState.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 = container.dashPayContactMetaStore + 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 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( + 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) { + // 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!!, + senderIdentityId = idBytes, + contactId = contactBytes, + contactDisplayName = displayName, + contactDpnsName = dpnsHint, + onSendingChange = { paymentSending = it }, + 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/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt new file mode 100644 index 00000000000..b6057c802ed --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -0,0 +1,352 @@ +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.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.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 scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + 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) } + + // 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 -> + val group = byContact[hex] ?: return@filter false + group.any { !it.isOutgoing } && group.none { it.isOutgoing } + }.toSet() + } + + 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..3cc84a9a4f5 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt @@ -0,0 +1,289 @@ +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.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 scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + 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/DashPayContactMeta.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt new file mode 100644 index 00000000000..45400b36214 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt @@ -0,0 +1,183 @@ +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) + "…" +} + +// ── 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 ─────────────────────────────────────────────────────────────── + +/** + * 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/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..2f81559246e --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -0,0 +1,286 @@ +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 +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.produceState +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.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 +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 + +/** + * 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) { + 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) } + + // 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("") } + 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 = qrBitmap + 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/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 new file mode 100644 index 00000000000..fac43a6e24d --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -0,0 +1,389 @@ +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.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.DashPayUnlockStatus +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 — 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. + * + * 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") }, + actions = { + IconButton(onClick = { refresh() }, modifier = Modifier.testTag("dashpay.refresh")) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { refresh() }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + 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 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) { + 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, +) { + // 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 + + 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", + // 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 = manager.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") + } finally { + isUnlocking = false + } + } + } + } else { + null + }, + ) + } + } +} + +@Composable +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), + 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, enabled = actionEnabled) { 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..64621184feb --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt @@ -0,0 +1,184 @@ +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.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.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 — 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) { + val container = LocalAppContainer.current + val appState = LocalAppState.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 = container.dashPayContactMetaStore + 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()) } + + // 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) -> + 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 new file mode 100644 index 00000000000..fa7cf1a6a1f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt @@ -0,0 +1,186 @@ +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.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.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 — 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) { + 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 } + } + + // 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() + 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..ca645fc43a1 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt @@ -0,0 +1,256 @@ +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.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 +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?, + onSendingChange: (Boolean) -> Unit, + 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 + onSendingChange(true) + errorMessage = null + scope.launch { + performDashPaySend( + sender = { + w.dashpay.sendPayment( + fromIdentityId = senderIdentityId, + toContactIdentityId = contactId, + amountDuffs = duffs, + coreSignerHandle = manager.mnemonicResolverHandle, + memo = null, + ) + }, + 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) + }, + ) + } + } + + 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") } + } + } + } +} + +/** 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/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/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/PARITY.md b/packages/kotlin-sdk/PARITY.md index 769aeffc234..b6f66322b3a 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) | — (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 (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 | +| 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.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. +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,7 +159,9 @@ Status legend: ## Totals -- **ported**: 88 (of 90 Swift views) +- **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 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" } 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/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/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt index a96f8e6bb9f..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 @@ -100,4 +101,162 @@ 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 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 { + 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")) + + // 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/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt new file mode 100644 index 00000000000..1ac52ed291e --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -0,0 +1,209 @@ +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? + + // ── 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 + + // ── 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 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, + 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/ffi/MnemonicNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt index ec04a4ff0a2..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 @@ -28,14 +28,40 @@ 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 + + /** + * 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 + } + /** - * 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/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 9e8c2061a3b..7d693d5ed62 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`. */ @@ -829,10 +858,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( @@ -859,6 +885,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/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/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 7913bb1fb25..7ebb99a7456 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,12 +7,14 @@ 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.CoreAddressPoolRestoreData import org.dashfoundation.dashsdk.ffi.CoreAddressRestoreData 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 @@ -26,6 +28,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 @@ -916,6 +919,62 @@ 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). 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( + 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, @@ -1423,6 +1482,47 @@ 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) + // 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, + 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, @@ -1435,6 +1535,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/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index 491fd685c15..4ae5efbf5a2 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 /** @@ -644,14 +645,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 f9a6a9e4e94..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,8 +148,11 @@ class KeystoreSigner( ) return } - 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, @@ -157,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 { @@ -173,13 +178,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)) } @@ -224,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/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt index 2d02f64fd78..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 @@ -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,31 @@ 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. 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/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 030b659fb4a..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 @@ -4,6 +4,8 @@ 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.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative /** @@ -251,6 +253,191 @@ 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) } + } + + // ── 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`. + * + * [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, + 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) ────────── + + /** + * 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] / + * [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 @@ -266,30 +453,76 @@ 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) + 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/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 2d698538432..0837b5d349d 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,13 +18,17 @@ 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 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 @@ -75,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 { @@ -484,7 +488,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) } @@ -498,7 +501,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) } @@ -512,11 +514,34 @@ class PlatformWalletManager( coreSendMutex = coreSendMutex(walletId.toHex()), ) restored.add(managed) - additions[walletId.toHex()] = managed - } - if (additions.isNotEmpty()) { - _wallets.update { it + additions } + // 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 + // 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) { + // 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. + } } + // Banner state (pendingAccountBuilds) must refresh whenever wallets + // exist, independent of whether the sweep service was started. + if (restored.isNotEmpty()) startDashPayStatusPolling() restored } @@ -552,6 +577,93 @@ 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 + // 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()) { + 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 + 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 + } + + /** 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) } } @@ -1087,6 +1199,252 @@ 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). 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) { + 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, 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. + * + * 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. 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. 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, + drainSigner.nativeHandle, + drainResolver.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) } + runCatching { drainResolver.close() } + runCatching { drainSigner.close() } + } + } + 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 @@ -1110,12 +1468,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) } @@ -1137,5 +1497,38 @@ 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: 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). */ + 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/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 430679149a9..2ca4dfa1944 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 @@ -978,6 +978,178 @@ 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 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 + // 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/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-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..15d4a55a662 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -0,0 +1,894 @@ +//! 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::{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; + +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 { + crate::support::throw_sdk_exception( + env, + 1, + &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(","))) + }) +} + +// ── 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"). 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; + } + let value: String = env.get_string(s).ok()?.into(); + std::ffi::CString::new(value).ok() +} + +// ── DIP-15 auto-accept QR ───────────────────────────────────────────── + +/// Build the owner's DIP-15 auto-accept QR payload +/// (`dash:?du=…&dapk=…`) for `identityId`, keying the proof through +/// `core_signer_handle` (bridges `platform_wallet_build_auto_accept_qr`). +/// `username` is the owner's DPNS name embedded in the URI and is required: +/// `platform_wallet_build_auto_accept_qr` rejects a null string, so callers +/// pass `""` for a nameless identity (the Kotlin `username: String` enforces +/// this). The null-tolerant marshalling below is a defensive backstop. +/// Returns the URI string; the Rust-owned C string is freed here. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_buildAutoAcceptQr( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + username: JString, + core_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 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 + }) +} 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/mnemonic.rs b/packages/rs-unified-sdk-jni/src/mnemonic.rs index d689edabefa..2e3778868bb 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,25 @@ 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; +const RESOLVE_OTHER: i32 = -3; + 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, @@ -51,43 +61,49 @@ 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)?; - let value = env + // Reserve one byte of the Rust capacity for the NUL terminator. + let usable = out_capacity - 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); - } + .i()?; - let jstr = JString::from(value); - let java_str = env.get_string(&jstr)?; - let mut bytes = java_str.to_bytes().to_vec(); - drop(java_str); - - 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, + RESOLVE_OTHER => RESULT_OTHER, + 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/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 63c1af64f0d..2ee64fedf7a 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -52,12 +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, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, + ContactRequestRemovalFFI, CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, + IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, PaymentRestoreEntryFFI, + PersistenceCallbacks, PlatformAddressFFI, SpentOutPointFFI, TokenBalanceRemovalFFI, + TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, + UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -864,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 ───────────────────────────────────────────────────── @@ -1563,23 +1612,45 @@ struct UnresolvedTxRecordStaged { } /// 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 @@ -1758,6 +1829,8 @@ fn seal_wallet_entries(staged: Vec) -> Vec = keys .into_iter() @@ -1813,6 +1886,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 }, ) @@ -2475,6 +2587,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, @@ -2489,13 +2646,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(), @@ -2508,6 +2658,93 @@ 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 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")?; + 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, }) } @@ -2815,14 +3052,15 @@ unsafe extern "C" fn tramp_load_wallet_list_free( e.last_applied_chain_lock_bytes_len, ); - // 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( @@ -2869,6 +3107,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/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index 44b32e41241..a6e3f9f7367 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -335,21 +335,27 @@ 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, ) -> 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 { @@ -357,19 +363,58 @@ 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(); }; - - // 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 { + 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, - "mnemonic or derivation path contained an interior NUL", + "derivation path contained an interior NUL", ); return ptr::null_mut(); }; + // 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, 2 => dash_network::ffi::FFINetwork::Devnet, @@ -387,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(), @@ -401,6 +446,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith ) }; + // 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( env, 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