diff --git a/docs/dashpay/KOTLIN_MIGRATION_SPEC.md b/docs/dashpay/KOTLIN_MIGRATION_SPEC.md new file mode 100644 index 00000000000..71b481b3d32 --- /dev/null +++ b/docs/dashpay/KOTLIN_MIGRATION_SPEC.md @@ -0,0 +1,433 @@ +# DashPay — Kotlin/Android Migration Spec + +Port the complete DashPay feature (as shipped for iOS in PR #3841, merged to +`v4.1-dev` 2026-07-06) to the Kotlin/Android SDK and KotlinExampleApp +(PR #3999, branch `feat/kotlin-sdk-and-example-app`). + +Status: **v2 — post five-lens review** (feasibility, scope, security, +adversarial, domain-fit); all must-fixes folded in. +Reference implementation: `packages/swift-sdk` + SwiftExampleApp. +Feature spec: `docs/dashpay/SPEC.md` (+ companion specs in `docs/dashpay/`). + +--- + +## 1. Problem + +The Kotlin SDK (PR #3999) is a one-for-one Android port of SwiftExampleApp, +snapshotted **before** PR #3841 landed. #3841 completed DashPay on iOS: + +- Replaced the utilitarian `FriendsView` (which the Kotlin `FriendsScreen` + mirrors — now a port of a **deleted** Swift view) with a first-class + **DashPay tab**: 10 views, ~3.8k LOC (`Views/DashPay/`). +- Added a recurring **DashPay background sync** service + (`platform_wallet_manager_dashpay_sync_*`, 7 FFI fns). +- Added **payment history**, **cached contact profiles**, **contactInfo** + (alias/note/hidden, cross-device), **ignore tombstones**, **seedless + unlock + deferred contact-crypto drain**, and **DIP-15 auto-accept QR**. +- Net: 23 new C exports in `rs-platform-wallet-ffi`; 6 old exports removed + (incl. the `*reject_contact_request` pair — reject became ignore). + +Today the Kotlin stack has: 3 of 5 DashPay Room entities (field-exact with +SwiftData), the send/accept/ignore/sync contact-request pipeline bridged +(17 JNI exports in `tokens.rs`), and one compressed `FriendsScreen`. It +lacks: profile/contactInfo writes, payments, the contact-profile cache, the +sync service, QR, seedless unlock, wallet-scoped DPNS search, and the +DashPay tab. `PARITY.md` still claims 88/90 ported against the stale +pre-#3841 view list. + +**Compatibility baseline:** the existing 17 exports were reconciled with +the #3841 Rust in commit `2298a2059f` (reject→ignore, `coreSignerHandle` +threading, contacts-vtable ignored-sender deltas + contactInfo metadata +fields, Room v1→v2). That reconciliation was verified by compile + 3 +Robolectric tests only — no runtime exercise of the Android +send/accept/ignore path against a network has been recorded since the +merge. K1 therefore opens with a runtime revalidation gate (§4). + +**Goal:** feature parity with iOS DashPay per the parity doctrine +(`kotlin-sdk/CLAUDE.md`): cite Swift sources in KDoc, reuse iOS +accessibility identifiers as Compose `testTag`s, keep all orchestration in +Rust. + +## 2. What does NOT need porting + +All DashPay *logic* is shared Rust, already compiled into this branch and +consumed by iOS through the same C ABI: + +- Contact-request crypto (ECDH, AES-256-CBC, DIP-15 69-byte compact xpub), + DIP-14/15 derivation, accountReference — `rs-platform-wallet` + + `platform-encryption` + `key-wallet`. +- Accept → reciprocal request → `register_external_contact_account` + (friendship address derivation) — automatic inside Rust. +- The recurring sync sweep (`manager/dashpay_sync.rs`), seed binding + verification, deferred contact-crypto queue, auto-accept QR + build/parse/proof. +- The bundled DashPay contract. + +Kotlin re-implements **none** of this. Per doctrine, no JNI function may +stitch Rust calls together — any composite gap found during the port goes +into `rs-platform-wallet-ffi` as its own reviewed change (none are +currently known to be needed; iOS ships on the existing exports). + +**Deliberately not bridged** (zero non-README callers in the Swift app — +the app reads this data from persisted rows, not handles; verified +function-by-function during review): + +- The 8 `contact_request_*` field getters + `contact_request_create`. +- The 14 `established_contact_*` accessors + + `managed_identity_get_established_contact` / + `_get_sent_contact_request` — `ContactDetailView` reads alias/note/ + hidden/paymentChannelBroken off `PersistentDashpayContactRequest` rows + and writes via `set_dashpay_contact_info_with_signer`. +- `managed_identity_is_contact_established`, + `platform_wallet_pubkey_hash_from_private_key` (no app consumers), + and the `managed_identity`-level send/accept/ignore variants (Kotlin + uses the `platform_wallet_*` composites, as iOS does). + +Consequence: **no new handle-wrapper types.** The existing +`ContactRequestRef` / `EstablishedContactRef` (`tokens/Dashpay.kt`) stay +as-is for the accept path; new screens are driven by Room Flows and +snapshot data classes. Rule: never retain a native handle in Compose +composition — snapshot fields at the JNI boundary (a Cleaner can free a +handle mid-read otherwise). + +## 3. Approach + +Three milestones, re-cut after review so that **every bridged function +lands in the milestone that first consumes and tests it** (the original +bottom-up "bridge everything first" cut concentrated all marshaling +defects at final UAT — rejected). + +### Alternatives rejected + +- **Re-orchestrating DashPay flows in Kotlin**: forbidden by doctrine; + Swift ships proof that single-call composites suffice. +- **Keeping `FriendsScreen` and bolting features onto it**: its Swift + counterpart was deleted; keeping a dead view's port violates parity. +- **uniffi / JNA instead of hand-rolled JNI**: the crate has 160 + hand-rolled exports with established support patterns (`support::guard`, + handle-as-jlong, GlobalRef callbacks); mixing binding generators adds + toolchain cost for no capability gain. +- **Bridging the full ~47-function FFI sweep**: 24 of them have no + consumer anywhere in the reference app (see §2); bridging them would + add dead surface + a speculative handle-wrapper abstraction. + +## 4. Work plan + +New JNI exports go in a new `rs-unified-sdk-jni/src/dashpay.rs` + +`ffi/DashpayNative.kt`; the 17 existing DashPay exports stay in +`tokens.rs` (moving them is a follow-up refactor). Array-free FFI +counterparts (`dashpay_payment_array_free`, `dpns_search_results_free`, +`platform_wallet_manager_free_account_balances`) are consumed Rust-side +inside the JNI wrappers, as the existing exports do. + +⚠ Lockstep rule: extending the identity-entry persist callback changes the +`onPersistIdentityUpsert` JNI method descriptor (hand-written string, +`persistence.rs:866-893`) — the Rust descriptor and the Kotlin +`NativePersistenceBridge` signature must change in the same commit or +every identity persist fails at runtime. + +### Milestone K1 — Persistence completion + the read surface it can prove + +**Entry gate:** one runtime revalidation of the existing 17-export +pipeline — the FriendsScreen send→accept→ignore flow against testnet +(`-Ptestnet=true`), or its instrumented equivalent — before any new +bridging. Also the 15-minute PARITY.md interim fix: drop the stale +`FriendsView.swift` row claims, mark the DashPay section +"in migration — see KOTLIN_MIGRATION_SPEC.md". + +**Bridge (6 exports):** + +| Group | Functions | +|---|---| +| Payments | `managed_identity_get_dashpay_payments` | +| Profile reads | `platform_wallet_get_contact_profile`, `managed_identity_get_dashpay_profile`, `managed_identity_get_dashpay_sync_state` | +| DPNS search | `platform_wallet_search_dpns_names` (wallet-scoped; the existing `QueriesNative.dpnsSearch` wraps the *SDK-scoped* `dash_sdk_dpns_search` — a different call path; AddContactScreen must use the wallet-scoped one for parity) | +| Account balances | `platform_wallet_manager_get_account_balances` (DashPayTabView's per-account balance display; not DashPay-prefixed, easy to miss) | + +**Persistence** (mirrors `PersistentDashpayContactProfile.swift` / +`PersistentDashpayPayment.swift`): + +- New Room entities `DashpayContactProfileEntity` + (`(networkRaw, ownerIdentityId, contactIdentityId)` unique, `checkedAtMs` + backoff) and `DashpayPaymentEntity` + (`(networkRaw, ownerIdentityId, txid)` unique) + DAOs + Flows; + `DashDatabase` v2→v3 additive migration, exported schema `3.json`. +- **Persist direction — contact profiles:** extend identity-entry + marshaling to carry `IdentityEntryFFI.contact_profiles` (slot exists, + currently skipped at `persistence.rs:825-895`). **Tombstone semantics + are load-bearing:** the projection emits `is_present == false` rows that + mean DELETE the persisted row (`identity_persistence.rs:600-608`) — an + upsert-only implementation compiles, passes upsert tests, and leaves + stale contact names/avatars forever. Required: `is_present=false` → DAO + delete (with test) + `checkedAtMs` round-trip fidelity test. (Note: the + outer doc comment at `identity_persistence.rs:146` contradicts the + projection code and should be corrected in passing.) +- **Persist direction — payments:** pull-based, mirroring iOS exactly. + **Invariant:** payment rows reach Room *only* via the + `refreshDashPayPayments` equivalent (FFI read → Room upsert); + `dashpay_sync_now` reconciles payments **in-memory without persisting** + (identity persist skips payments, `identity_persistence.rs:37-39`). + Android process death is aggressive, so K3's send flow must call + refresh-after-send, and the K1 test suite pins the invariant. +- **Restore direction:** populate the null-stubbed `contact_profiles` / + `payments` arrays in `rs-unified-sdk-jni/src/persistence.rs` + (staging comment :1476-1480, stubs :1848-1853, free-path :2049-2051), + mirroring the Swift restore blocks + (`PlatformWalletPersistenceHandler.swift` ~4918, ~4978). + +**Gate (re-tiered after review):** the persist→wipe→restore→re-read +round-trip runs as an **instrumented test** (extend +`sdk/src/androidTest/.../WalletManagerRoundTripTest.kt`, which already +drives the real native lib on the CI emulator) — payload includes payments +with memo/direction/status, a contact-profile `is_present=false` tombstone, +and ignored senders; the new K1 getters read back restore-injected fixtures +and assert field equality. JVM/Robolectric tests cover handler↔Room mapping +only (they never load the native lib, so they cannot see marshaling bugs — +this was the original spec's top-listed risk guarded by the wrong tier). + +### Milestone K2 — Sync service, seedless unlock, writes + +**Pre-req (security must-fix): mnemonic-handling discipline.** The +existing Kotlin resolver materializes the mnemonic as an immutable JVM +`String` (`WalletStorage.retrieveMnemonic` does `decodeToString()` and +scrubs only the ByteArray; `MnemonicResolverAndPersister.kt:36-42` returns +the String to native). iOS never creates a string-shaped copy: it keeps +XOR-masked UTF-8 bytes (`MaskedMnemonicUTF8`) and writes into Rust's +out-buffer, scrubbing on every access. K2 multiplies resolver calls (the +drain runs it once per queued entry), so **before** wiring the automatic +drain: port the out-buffer + masked-bytes discipline to `WalletStorage.kt` +/ `MnemonicResolverAndPersister.kt` / `mnemonic.rs` (whose "same residual +exposure as iOS" comment is false and must be corrected), and zeroize the +`mnemonic_str`/`mnemonic_c` copies in `signer.rs:342-422` (currently only +`sig_buf` is scrubbed). + +**Bridge (13 exports):** the 7 `platform_wallet_manager_dashpay_sync_*` +fns; the seedless trio `platform_wallet_verify_seed_binds_to_wallet`, +`platform_wallet_pending_contact_crypto_count`, +`platform_wallet_drain_pending_contact_crypto` (takes **both** a +`SignerHandle` and a `MnemonicResolverHandle` — +`rs-platform-wallet-ffi/src/dashpay.rs:757-761`); +`platform_wallet_create_or_update_dashpay_profile_with_signer`, +`platform_wallet_set_dashpay_contact_info_with_signer`; +`dash_sdk_resolver_supports_key_type` (rs-sdk-ffi; consumed by the +production signer — Swift `KeychainSigner.swift`). + +**`DashpaySyncService`** (`sdk/services/`, mirroring +`PlatformWalletManagerDashPaySync.swift`): + +- Owned by the `PlatformWalletManager` instance; started when platform + wallets are present (after load / on the rebind path), stopped and + disposed on the `WalletManagerStore` manager swap. **Not gated on + process lifecycle** — iOS keeps the sweep running while backgrounded + (the OS suspends the process; Android freezes/kills similarly under + modern app-standby), and this matches the manager-owned ownership + doctrine. (The v1 spec's "mirrors scenePhase" claim was wrong — Swift + drives start/stop off wallet-presence/rebind `.onChange`, not + scenePhase. No `lifecycle-process` dependency needed.) +- `isSyncing` / `lastSync` / `pendingAccountBuilds` exposed as `StateFlow`, + updated by a **1 Hz poll with change-gated assignment and stale-key + pruning** — pinned now, not "verify later": that is exactly how iOS does + it (`PlatformWalletManager.swift:1140-1186`; the comment at :1133-1139 + documents why naive re-assignment burned CPU). Natural home: the + `SpvProgressPublisher` pattern. + +**Seedless unlock — invocation topology (domain must-fix; the API method +alone is not the feature):** + +- `unlockWalletFromKeystore(walletId)` = scoped verify → drain, and is + called **automatically, per restored wallet, inside + `loadPersistedWallets`**, best-effort/never-throwing (mirrors + `PlatformWalletManager.swift:477-506`; Kotlin's + `PlatformWalletManager.kt:457` currently documents the absence). This is + load-bearing: the deferred contact-crypto queue is **in-memory by + design** (no persisted table on either platform — do not "fix" that) and + recovery is self-healing only if every launch runs + load → unlock (verify+drain) → sweep. Banner-triggered-only unlock would + leave contacts never finishing establishment after process restart. +- **Seed-mismatch contract:** Rust `SeedMismatch` surfaces as + `ErrorInvalidParameter` (`rs-platform-wallet-ffi/src/dashpay.rs:960-965`); + like Swift, Kotlin disambiguates *only* by scoping the catch to the + verify call (the JNI error code arrives as + `code + PWFFI_CODE_OFFSET`). Publish per-wallet + `draining` / `seedMismatch` / `pendingAccountBuilds` as `StateFlow` + (Swift: `dashPayUnlockStatus`). +- **Re-entrancy guard:** a second unlock while `draining == true` returns + immediately (Swift :622-624) — load-time auto-drain and a user banner + tap must not double-run the ECDH work. +- **Biometric interaction (Android-only failure mode):** Kotlin's + identity-key Keystore alias is auth-gated (30 s validity + + `BiometricGate`) — *stricter than iOS*, whose identity keys are not + auth-gated at all. The reciprocal-accept signing inside a background + drain can therefore throw on an expired auth window with no Activity to + re-prompt. Required behavior: catch, leave the entry queued (the sweep + self-heals), reflect it in the unlock status; instrumented test for the + auth-expired path. +- **Breadcrumb backfill — explicitly not ported.** Swift's unlock also + schedules `scheduleBackfillIdentityKeyBreadcrumbs`, an iOS-legacy + Keychain healing step for pre-breadcrumb installs. The Kotlin SDK is new + — every identity key it has ever created is breadcrumbed at creation — + so there is nothing to heal. Recorded here so its absence is a decision, + not an oversight. + +**Gate:** unit tests + instrumented sync-service lifecycle tests +(start/stop/isRunning; manager-swap disposal; double-start), unlock state +machine with a wrong-seed fixture (seedMismatch path) and the +auth-expired-drain path; write paths against testnet behind +`-Ptestnet=true`. + +### Milestone K3 — DashPay tab UI + parity bookkeeping + +**Bridge (2 exports):** `platform_wallet_build_auto_accept_qr`, +`platform_wallet_send_contact_request_from_qr` (first consumed here). + +Navigation restructure (mirrors Swift `ContentView.swift`): + +- `RootTab`: `SYNC, WALLETS, IDENTITIES, DASHPAY, SETTINGS` — **Contracts + tab is demoted into Settings** (`ContractsHome` becomes an entry in the + Settings screen's Platform section, as on iOS). +- Retire `FriendsScreen` + its route; entry points repoint at the DashPay + tab/contact flows. +- Reset the DashPay tab's identity-picker selection on network switch — + retained Compose state would otherwise query a wallet absent on the new + network-locked manager. + +Port the 10 Swift views (Compose screens under `ui/dashpay/`, one file per +Swift file, testTags = iOS accessibility identifiers, screens driven by +Room Flows / snapshot data classes — never by retained native handles): + +| Swift (`Views/DashPay/`) | Compose target | Notes | +|---|---|---| +| DashPayTabView (909) | `DashPayTabScreen` | identity picker, per-account balances, pull-to-refresh → `syncNow`, unlock banner (reads unlock-status Flow), sub-sheet navigation | +| ContactsView (299) | `ContactsScreen` | Room Flow over established contacts (both-direction join) | +| ContactRequestsView (391) | `ContactRequestsScreen` | incoming accept/ignore + outgoing pending | +| AddContactView (497) | `AddContactScreen` | wallet-scoped DPNS prefix search (300 ms debounce), raw id, QR entry | +| ContactDetailView (561) | `ContactDetailScreen` | payment history (refresh→Room), alias/note editors **surfacing the `ContactInfoPublishOutcome`** — `DeferredUntilTwoContacts` means local-only until ≥2 established contacts and the UI must say so (parity), `SkippedWatchOnly` likewise; hide; send-payment entry | +| SendDashPayPaymentSheet (386) | `SendDashPayPaymentSheet` | amount/memo → `sendDashPayPayment` → txid, then **refresh-after-send** (payments-durability invariant, §K1) | +| DashPayProfileView (188) | `DashPayProfileScreen` | own profile display/edit + auto-accept QR render (ZXing — already a dependency) | +| IgnoredContactsView (181) | `IgnoredContactsScreen` | unignore | +| HiddenContactsView (240) | `HiddenContactsScreen` | unhide | +| DashPayContactMeta (183) | `DashPayContactMeta.kt` | meta store (UserDefaults → SharedPreferences/DataStore; plaintext is parity — iOS documents UserDefaults as "the honest backing" for device-local data), display-name precedence, avatar composable | + +QR scan reuses the already-ported `QrScannerScreen`; the auto-accept +scan-to-send path calls `sendContactRequestFromQR`. + +**New dependency:** Coil (`coil-compose`) for avatar loading — not +currently in `libs.versions.toml`; flagged here because it is the plan's +only new third-party dependency. (ZXing is already present.) + +Parity bookkeeping: full `PARITY.md` DashPay rewrite — a `Views/DashPay/` +section with one row per view, corrected totals (interim stale-claims fix +already landed in K1). + +**Gate:** `:app:assembleDebug` + Compose UI tests mirroring +`DashPayTabUITests.swift`; manual UAT next to the iOS simulator per +`QA_TESTCASES_SPEC.md` flows, including the end-to-end +send→accept→pay testnet run. + +## 5. Interfaces & data flow (summary) + +``` +Compose UI ── StateFlow/Room Flow ── PlatformWalletManager / Dashpay.kt + │ │ (thin, marshal-only) + │ DashpayNative.kt (external fun) + │ │ JNI + ▼ rs-unified-sdk-jni/src/dashpay.rs + Room (Dashpay* entities) │ rlib call + ▲ rs-platform-wallet-ffi (C ABI) + │ persistence callbacks │ + └── NativePersistenceBridge ◄── rs-platform-wallet (all logic) +``` + +- Writes require two callback handles: identity signer (`signer.rs`) and + mnemonic resolver (`mnemonic.rs`) — both exist; DashPay adds no new + callback *types*, only new call sites. Handles are kept strongly + referenced for the duration of each call (GC hazard). +- Contact profiles ride the identity persist/restore callback path + (with tombstone-delete semantics); payments are pull-persisted and + array-restored. The deferred contact-crypto queue is deliberately + not persisted (in-memory + sweep self-heal, both platforms). +- Cold-start contract: `loadPersistedWallets` → per-wallet best-effort + unlock (verify → drain) → sync service start → recurring sweep. +- Threading: FFI calls on `Dispatchers.IO`; Rust→Kotlin callbacks attach + as JVM daemon threads; persistence-handler read-modify-writes run in + Room transactions (callback threads race UI-triggered refreshes + otherwise). + +## 6. Failure modes / risks + +- **Restore-path marshaling bugs** corrupt Rust wallet state on load. + Guard: the K1 instrumented round-trip (real native lib) — JVM tests + cannot see this code. +- **Contact-profile staleness:** upsert-only persist misses tombstones → + stale names/avatars forever. Guard: `is_present=false` delete test (K1). +- **Payment loss on process death:** `syncNow` does not persist payments. + Guard: refresh-after-send + kill/relaunch test (K1/K3). +- **Never-draining wallets:** unlock not wired into load → contacts stuck + pending after every restart. Guard: unlock topology spec (§K2) + + restore→unlock instrumented test. +- **Background drain vs biometric gate:** auth-expired signing during + drain must requeue, not fail silently. Guard: auth-expired test (K2). +- **GC vs callback lifetime** during long drains: strong refs on + signer/resolver bridges per call site; drain stress test. +- **Sync-service leak across network switch:** manager-owned lifecycle, + disposed on `WalletManagerStore` swap; double-start test. UI-side: + identity-picker reset on network change. +- **JNI descriptor lockstep** on the identity persist callback (§4 note): + Rust descriptor + Kotlin signature in one commit. +- **Room migration:** additive-only v3; migration test against `2.json`. +- **Build env:** exFAT gotcha (`build_android.sh` sparse image), NDK r28+, + 16 KB alignment — K1 ends with `./build_android.sh --verify` passing. + +## 7. Test plan + +1. **Instrumented (`connectedDebugAndroidTest`, CI emulator)** — the + load-bearing tier: extend `WalletManagerRoundTripTest` with the DashPay + persist→wipe→restore→re-read round-trip (payments incl. memo/direction/ + status, contact-profile tombstone, ignored senders); K1 getters read + restore-injected fixtures; K2 sync-service lifecycle, unlock + state machine (wrong-seed → seedMismatch; auth-expired drain). +2. **JVM unit (`:sdk:testDebugUnitTest`)** — handler↔Room mapping only + (explicitly *not* the marshaling tier): contact-profile + upsert/delete/backoff, payment row mapping, Room v2→v3 migration. +3. **Compose UI tests** — port `DashPayTabUITests.swift` flows using the + shared testTags (tab presence, add-contact form, requests accept path + with a fake bridge). +4. **Testnet opt-in (`-Ptestnet=true`)** — K1 entry gate: existing + FriendsScreen send→accept→ignore revalidation. K3 exit: end-to-end + send→accept→pay between two fixture identities, mirroring iOS UAT. +5. **CI** — existing `kotlin-sdk-build.yml` runs tiers 1–3. + +## 8. Out of scope + +- Invitations (SPEC.md Milestone 5) — not implemented on iOS either. +- The 24 unconsumed FFI functions listed in §2 (and any new handle-wrapper + types for them). +- `managed_identity_get_contested_dpns_names` — its consumers + (SelectMainName / WalletMemoryExplorer / IdentityDetail) are outside + `Views/DashPay/`; it belongs to the existing non-DashPay PARITY-partial + bucket, not this migration. +- Identity-key breadcrumb backfill (justified in §K2 — no pre-breadcrumb + Android installs can exist). +- Migrating the 17 pre-existing DashPay JNI exports out of `tokens.rs`. +- The 5 non-DashPay `TransitionDetailView` FFI gaps and other PARITY + "partial" items. +- Any change to Rust crates other than `rs-unified-sdk-jni` (a genuine + composite gap, if found, becomes its own reviewed `rs-platform-wallet-ffi` + change). + +## 9. Decisions taken in this spec (previously open) + +- **One PR per milestone** — the milestones carry independent gates by + design; review units should match. +- **Sync lifecycle: manager-owned, not process-lifecycle-gated** (§K2). +- **Poll (1 Hz, change-gated), not events, for sync/unlock status** (§K2). +- **Coil added** as the single new dependency (§K3). + +## 10. Open questions (for Ivan) + +1. **Branch/PR strategy:** land the K-milestones as stacked PRs on top of + `feat/kotlin-sdk-and-example-app` (PR #3999 is already ~50k insertions), + or fold into #3999? Recommendation: **stacked PRs**. +2. **Tab restructure confirmation:** mirroring Swift means demoting the + Contracts tab into Settings on Android too. Confirm parity wins over + Android-specific navigation taste. diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index c622c24a1c1..061af19d7f0 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -34,7 +34,7 @@ Status legend: | DocumentTypeDetailsView.swift | ui/contracts/DocumentTypeDetailsScreen.kt · `DocumentTypeDetail` | ported | | DocumentWithPriceView.swift | ui/contracts/DocumentWithPriceScreen.kt · `DocumentWithPrice` | ported — debounced document-id probe (price / owner / ownership badging via `Documents.fetch`), plus the purchase (`DocumentTransactions.purchase` → `platform_wallet_document_purchase`) and owner set-price (`DocumentTransactions.setPrice` → `platform_wallet_document_set_price`) submit flows that live in `PurchaseDocumentView` / the set-price sheet on iOS, hosted as one screen; entries from DocumentsScreen rows ("Price…") and the transition catalog | | DocumentsView.swift | ui/contracts/DocumentsScreen.kt · `Documents` | ported (query role; viewer role in DocumentFieldsScreen; the row-level Purchase… / Set Price… actions drill into DocumentWithPriceScreen; create / replace / delete / transfer stay unbridged — see TransitionDetailView) | -| FriendsView.swift | ui/identity/FriendsScreen.kt · `Friends` | ported — full `loadFriends()` hydration: network sync (`platform_wallet_sync_contact_requests` / `platform_wallet_fetch_sent_contact_requests`) + managed-identity id enumeration (`platform_wallet_get_managed_identity` → `managed_identity_get_{incoming,sent}_contact_request_ids` / `..._established_contact_ids`, Room fallback); send / reject / accept all wired (accept via `managed_identity_get_incoming_contact_request` + the bridged `platform_wallet_accept_contact_request_with_signer`) | +| ~~FriendsView.swift~~ (deleted upstream) | ui/identity/FriendsScreen.kt · `Friends` | **in migration** — the Swift view was deleted by PR #3841 and replaced by the first-class DashPay tab (`Views/DashPay/`, 10 views: DashPayTabView, ContactsView, ContactRequestsView, AddContactView, ContactDetailView, SendDashPayPaymentSheet, DashPayProfileView, IgnoredContactsView, HiddenContactsView, DashPayContactMeta), none of which are ported yet. FriendsScreen still covers a slice (sync / list / send / accept / ignore over the 17 bridged exports, reconciled with the post-#3841 FFI in `2298a2059f`); it is retired and superseded per `docs/dashpay/KOTLIN_MIGRATION_SPEC.md` (milestones K1–K3) | | FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow | | TransferPlatformAddressView.swift (ADDR-02, #3923) | ui/credits/TransferPlatformAddressScreen.kt · `TransferPlatformAddress` | ported — wallet-signed DIP-17 credit transfer via the now-bridged `platform_address_wallet_transfer` (`ManagedPlatformWallet.transferCredits`, AUTO selection, null inputs/fee-strategy); source account + destination (own-wallet / external P2PKH hash) + amount only; gate reads version-locked `minInput`/`minOutput` via `walletPlatformAddressMinAmounts`. Launched from WalletDetailScreen's Platform Credits section | | WithdrawPlatformAddressView.swift (ADDR-04, #3923) | ui/credits/WithdrawPlatformAddressScreen.kt · `WithdrawPlatformAddress` | ported — wallet-signed full-balance DIP-17 withdrawal to a Core L1 address via the now-bridged `platform_address_wallet_withdraw_to_address` (`ManagedPlatformWallet.withdrawCredits`); submit gated on `platform_address_wallet_preflight_withdrawal` (`preflightWithdrawal`, off the main thread on `Dispatchers.IO`), and when the gate refuses, the advisory "why not" from `preflightWithdrawalReason` (`platform_address_wallet_preflight_withdrawal_reason`) renders under the status row; Fibonacci fee-rate picker mirrors `WithdrawalCoreFeeRates`. Launched from WalletDetailScreen's Platform Credits section | @@ -129,9 +129,13 @@ Status legend: ## Totals -- **ported**: 88 (of 90 Swift views) +- **ported**: 87 (of the 90 pre-#3841 Swift views; the FriendsView row above + no longer counts — its Swift source is deleted) - **partial**: 2 (TransitionDetailView — 5 of 23 catalog entries lack backing FFIs: dataContractUpdate, documentCreate/Replace/Delete/Transfer; WalletMemoryExplorerView — asset-lock drill-down summary only) - **deferred**: 0 +- **in migration**: the DashPay tab (10 `Views/DashPay/` views added by + PR #3841) — see `docs/dashpay/KOTLIN_MIGRATION_SPEC.md`; this table gets + its `Views/DashPay/` section when milestone K3 lands Every partial/deferred row names the missing FFI export; the app surfaces the same name in a dialog at the point of use (grep for diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json new file mode 100644 index 00000000000..024d845d4a9 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/3.json @@ -0,0 +1,3842 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "9e53afeba6e8c513955ed48bcfb937f4", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `privateKeyKeychainIdentifier` TEXT, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_platform_addresses_addressHash", + "unique": true, + "columnNames": [ + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_addressHash` ON `${TABLE_NAME}` (`addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9e53afeba6e8c513955ed48bcfb937f4')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt new file mode 100644 index 00000000000..4d38717f904 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -0,0 +1,95 @@ +package org.dashfoundation.dashsdk.persistence + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Migration tests over the exported schemas (`sdk/schemas`, wired as + * androidTest assets). Each test creates the database at the FROM + * version, seeds representative rows, runs the migration under test, and + * lets [MigrationTestHelper] validate the migrated schema byte-for-byte + * against the exported TO-version JSON — catching any drift between the + * hand-written SQL in [DashDatabase] and what Room generates. + */ +@RunWith(AndroidJUnit4::class) +class DashDatabaseMigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + DashDatabase::class.java, + ) + + private val dbName = "migration-test.db" + + /** + * v2 → v3 adds the `dashpay_contact_profiles` and `dashpay_payments` + * tables (additive — no reshapes). Pre-existing v2 data must survive + * untouched and the new tables must accept rows keyed by their + * compound PKs. + */ + @Test + fun migrate2To3AddsContactProfileAndPaymentTables() { + helper.createDatabase(dbName, 2).apply { + // Seed a v2 wallet + identity so FK targets exist post-migration. + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + execSQL( + "INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " + + "createdAt, lastUpdated, networkRaw, identityIndex) " + + "VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 3, true, DashDatabase.MIGRATION_2_3) + + // Pre-existing rows survived. + db.query("SELECT COUNT(*) FROM identities").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + // The new tables accept rows under their compound keys. + db.execSQL( + "INSERT INTO dashpay_contact_profiles (networkRaw, ownerIdentityId, " + + "contactIdentityId, checkedAtMs, createdAt, lastUpdated) " + + "VALUES (1, x'0A', x'0B', 123, 0, 0)", + ) + db.execSQL( + "INSERT INTO dashpay_payments (networkRaw, ownerIdentityId, " + + "counterpartyIdentityId, amountDuffs, directionRaw, statusRaw, txid, " + + "createdAt, lastUpdated) VALUES (1, x'0A', x'0B', 5, 0, 1, 'ab', 0, 0)", + ) + db.query("SELECT COUNT(*) FROM dashpay_contact_profiles").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + db.query("SELECT COUNT(*) FROM dashpay_payments").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + db.close() + } + + /** The full chain from v1 must also land on a valid v3 schema. */ + @Test + fun migrateAllTheWayFrom1() { + helper.createDatabase(dbName, 1).close() + helper.runMigrationsAndValidate( + dbName, + 3, + true, + DashDatabase.MIGRATION_1_2, + DashDatabase.MIGRATION_2_3, + ).close() + } +} diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt index a96f8e6bb9f..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..2f127d39744 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -0,0 +1,75 @@ +package org.dashfoundation.dashsdk.ffi + +/** + * JNI bindings for the DashPay read surface added by the iOS DashPay + * completion (upstream #3841) — payment history, cached contact + * profiles, per-identity sync state, wallet-scoped DPNS search and + * per-account wallet balances. Rust side: + * `rs-unified-sdk-jni/src/dashpay.rs`. The pre-existing DashPay + * send/accept/ignore/sync pipeline lives in [TokensNative]. + * + * Read results are compact JSON strings (the + * [TokensNative.getDashPayProfile] precedent — parsing happens + * Kotlin-side); 32-byte ids inside the JSON are lower-hex. Errors throw + * [DashSDKException]. + */ +internal object DashpayNative { + + /** + * DashPay payment history off a managed-identity handle. Returns a + * JSON array — per payment: `txid`, `counterpartyId` (hex), + * `amountDuffs`, `direction` (0 Sent, 1 Received), `status` + * (0 Pending, 1 Confirmed, 2 Failed), optional `memo`. + * ← Swift `ManagedIdentity.getDashPayPayments()`. + */ + external fun managedIdentityDashPayPayments(identityHandle: Long): String? + + /** + * Cached profile of [contactIdentityId] as seen by + * [ownerIdentityId], or null when none is cached. Same JSON shape as + * [TokensNative.getDashPayProfile]. + * ← Swift `ManagedPlatformWallet.getContactProfile(owner:contact:)`. + */ + external fun getContactProfile( + walletHandle: Long, + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ): String? + + /** + * The managed identity's own cached DashPay profile off its handle, + * or null. ← Swift `ManagedIdentity.getDashPayProfile()`. + */ + external fun managedIdentityDashPayProfile(identityHandle: Long): String? + + /** + * DashPay sync state off a managed-identity handle: JSON object of + * collection counts (`establishedContacts`, `incomingRequests`, + * `sentRequests`, `ignoredSenders`, `contactProfiles`, + * `presentContactProfiles`, `dashpayPayments`, `hasDashpayProfile`) + * plus the optional `highWaterReceivedMs` / `highWaterSentMs` + * cursors (keys absent when unset). + * ← Swift `ManagedIdentity.getDashPaySyncState()`. + */ + external fun managedIdentityDashPaySyncState(identityHandle: Long): String? + + /** + * Live DPNS prefix search against Platform, wallet-scoped (the call + * path iOS `AddContactView` drives — distinct from the + * SDK-handle-scoped `QueriesNative.dpnsSearch`). Returns a JSON + * array of `{"label":…,"identityId":…hex}`; `limit` 0 = no limit. + * Blocking (network). ← Swift + * `ManagedPlatformWallet.searchDpnsNames(prefix:limit:)`. + */ + external fun searchDpnsNames(walletHandle: Long, prefix: String, limit: Int): String? + + /** + * Per-account balance snapshot for [walletId] off the manager + * handle. Returns a JSON array — per account: `typeTag`, + * `standardTag`, `index`, `registrationIndex`, `keyClass`, + * `userIdentityId` / `friendIdentityId` (hex, all-zero when unset), + * `confirmed`, `unconfirmed`, `immature`, `locked`, `keysUsed`, + * `keysTotal`. ← Swift `PlatformWalletManager.accountBalances(for:)`. + */ + external fun walletManagerAccountBalances(managerHandle: Long, walletId: ByteArray): String? +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 32451ae800b..2dd75d178fa 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 @@ -392,6 +392,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`. */ @@ -573,10 +602,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( @@ -603,6 +629,53 @@ class IdentityRestoreData( * sender resurfaces after every relaunch. */ @JvmField val ignoredSenders: Array, + /** + * DashPay payment-history rows to rehydrate the Rust + * `dashpay_payments` map at load — without this *Sent* entries (and + * their user-entered memos) vanish on every relaunch; the reconcile + * sweep can only re-derive *Received* entries from UTXOs. + */ + @JvmField val payments: Array, + /** + * Cached contact-profile rows (present profiles only — tombstones + * delete the Room row at persist time) to rehydrate the Rust + * `contact_profiles` map at load — without this the contacts UI + * shows raw identity ids after relaunch until the next profile sweep + * re-fetches every contact. + */ + @JvmField val contactProfiles: Array, +) + +/** + * One flat DashPay payment-history restore row — mirror of + * `PaymentRestoreEntryFFI`. `directionRaw`: 0 Sent, 1 Received; + * `statusRaw`: 0 Pending, 1 Confirmed, 2 Failed. [memo] null mirrors the + * source `Option` being `None`. + */ +class PaymentRestoreData( + @JvmField val txid: String, + @JvmField val counterpartyId: ByteArray, + @JvmField val amountDuffs: Long, + @JvmField val directionRaw: Byte, + @JvmField val statusRaw: Byte, + @JvmField val memo: String?, +) + +/** + * One flat cached contact-profile restore row — mirror of + * `ContactProfileRestoreEntryFFI`. [avatarHash] / [avatarFingerprint] + * are null when absent (the trampoline derives the FFI `_present` flags + * from nullability + length: 32 / 8 bytes respectively). + */ +class ContactProfileRestoreData( + @JvmField val contactId: ByteArray, + @JvmField val displayName: String?, + @JvmField val bio: String?, + @JvmField val avatarUrl: String?, + @JvmField val avatarHash: ByteArray?, + @JvmField val avatarFingerprint: ByteArray?, + @JvmField val publicMessage: String?, + @JvmField val checkedAtMs: Long, ) /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 7ae51d9b294..2143e262ccf 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -28,8 +28,10 @@ import org.dashfoundation.dashsdk.persistence.dao.WalletManagerMetadataDao import org.dashfoundation.dashsdk.persistence.entities.AccountEntity import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayIgnoredSenderEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity import org.dashfoundation.dashsdk.persistence.entities.DocumentEntity @@ -70,9 +72,16 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * established-row metadata columns on `dashpay_contact_requests` * (`paymentChannelBroken` / `contactAlias` / `contactNote` / * `contactHidden` / `contactAccountLabel` / `contactAcceptedAccounts`). + * + * Version 3 (DashPay contact-profile cache + payment history): adds the + * `dashpay_contact_profiles` table (mirror of the Rust + * `contact_profiles` map — persister-projected with tombstone deletes, + * restored at load) and the `dashpay_payments` table (mirror of the Rust + * `dashpay_payments` map — pull-persisted via `refreshDashPayPayments`, + * restored at load). */ @Database( - version = 2, + version = 3, exportSchema = true, entities = [ WalletEntity::class, @@ -87,6 +96,8 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti DashpayProfileEntity::class, DashpayContactRequestEntity::class, DashpayIgnoredSenderEntity::class, + DashpayContactProfileEntity::class, + DashpayPaymentEntity::class, DataContractEntity::class, DocumentTypeEntity::class, DocumentEntity::class, @@ -176,6 +187,59 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v2 → v3: the DashPay contact-profile cache + payment-history + * tables. Purely additive. SQL mirrors the exported + * `schemas/.../3.json` `createSql` exactly (column order = entity + * field order). + */ + val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `dashpay_contact_profiles` (" + + "`networkRaw` INTEGER NOT NULL, " + + "`ownerIdentityId` BLOB NOT NULL, " + + "`contactIdentityId` BLOB NOT NULL, " + + "`displayName` TEXT, " + + "`publicMessage` TEXT, " + + "`bio` TEXT, " + + "`avatarUrl` TEXT, " + + "`avatarHash` BLOB, " + + "`avatarFingerprint` BLOB, " + + "`checkedAtMs` INTEGER NOT NULL, " + + "`createdAt` INTEGER NOT NULL, " + + "`lastUpdated` INTEGER NOT NULL, " + + "PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), " + + "FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` " + + "ON `dashpay_contact_profiles` (`ownerIdentityId`)", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `dashpay_payments` (" + + "`networkRaw` INTEGER NOT NULL, " + + "`ownerIdentityId` BLOB NOT NULL, " + + "`counterpartyIdentityId` BLOB NOT NULL, " + + "`amountDuffs` INTEGER NOT NULL, " + + "`directionRaw` INTEGER NOT NULL, " + + "`statusRaw` INTEGER NOT NULL, " + + "`txid` TEXT NOT NULL, " + + "`memo` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`lastUpdated` INTEGER NOT NULL, " + + "PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), " + + "FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` " + + "ON `dashpay_payments` (`ownerIdentityId`)", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -184,7 +248,7 @@ abstract class DashDatabase : RoomDatabase() { */ fun create(context: Context): DashDatabase = Room.databaseBuilder(context, DashDatabase::class.java, DATABASE_NAME) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() /** In-memory variant for tests. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index d9bc6cab316..2e9cd6a664f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -7,10 +7,12 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.dashfoundation.dashsdk.ffi.AccountSpecData +import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData import org.dashfoundation.dashsdk.ffi.ContactRequestRestoreData import org.dashfoundation.dashsdk.ffi.CoreTxRecordData import org.dashfoundation.dashsdk.ffi.IdentityKeyRestoreData import org.dashfoundation.dashsdk.ffi.IdentityRestoreData +import org.dashfoundation.dashsdk.ffi.PaymentRestoreData import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.ShieldedActivityData import org.dashfoundation.dashsdk.ffi.ShieldedNoteData @@ -20,6 +22,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 @@ -896,6 +899,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, @@ -1351,6 +1410,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, @@ -1363,6 +1463,8 @@ class PlatformWalletPersistenceHandler( keys = keyRows, contacts = contactRows, ignoredSenders = ignoredRows, + payments = paymentRows, + contactProfiles = contactProfileRows, ) }.toTypedArray() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt index 5ccda5fb8d1..1948639fd38 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DashpayDao.kt @@ -4,8 +4,10 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow +import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayIgnoredSenderEntity +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity /** @@ -132,4 +134,97 @@ interface DashpayDao { /** StorageExplorer network-scoped row count. */ @Query("SELECT COUNT(*) FROM dashpay_contact_requests WHERE networkRaw = :networkRaw") fun countContactRequestsByNetwork(networkRaw: Int): Flow + + // MARK: Cached contact profiles + + /** Mirror of `PersistentDashpayContactProfile.predicate(ownerIdentityId:)`. */ + @Query("SELECT * FROM dashpay_contact_profiles WHERE ownerIdentityId = :ownerIdentityId") + fun observeContactProfiles( + ownerIdentityId: ByteArray, + ): Flow> + + /** Mirror of `predicate(ownerIdentityId:contactIdentityId:)`. */ + @Query( + "SELECT * FROM dashpay_contact_profiles WHERE ownerIdentityId = :ownerIdentityId " + + "AND contactIdentityId = :contactIdentityId" + ) + fun observeContactProfile( + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ): Flow + + /** Persister upsert (`is_present == true` changeset entry). */ + @Upsert + suspend fun upsertContactProfile(profile: DashpayContactProfileEntity) + + /** + * Persister tombstone (`is_present == false` changeset entry): the + * contact removed their on-chain profile, so the cached row must go — + * an upsert-only pipeline would leave a stale name/avatar forever. + */ + @Query( + "DELETE FROM dashpay_contact_profiles WHERE networkRaw = :networkRaw " + + "AND ownerIdentityId = :ownerIdentityId " + + "AND contactIdentityId = :contactIdentityId" + ) + suspend fun deleteContactProfile( + networkRaw: Int, + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ) + + /** Restore-path read: every cached contact profile owned by [ownerIdentityId]. */ + @Query("SELECT * FROM dashpay_contact_profiles WHERE ownerIdentityId = :ownerIdentityId") + suspend fun getContactProfilesByOwner( + ownerIdentityId: ByteArray, + ): List + + @Query("DELETE FROM dashpay_contact_profiles") + suspend fun deleteAllContactProfiles() + + /** StorageExplorer row count. */ + @Query("SELECT COUNT(*) FROM dashpay_contact_profiles") + fun countContactProfiles(): Flow + + /** StorageExplorer network-scoped row count. */ + @Query("SELECT COUNT(*) FROM dashpay_contact_profiles WHERE networkRaw = :networkRaw") + fun countContactProfilesByNetwork(networkRaw: Int): Flow + + // MARK: Payments (pull-persisted; see DashpayPaymentEntity KDoc) + + /** Mirror of `PersistentDashpayPayment.predicate(ownerIdentityId:)`. */ + @Query("SELECT * FROM dashpay_payments WHERE ownerIdentityId = :ownerIdentityId") + fun observePayments(ownerIdentityId: ByteArray): Flow> + + /** + * Mirror of `predicate(ownerIdentityId:counterpartyIdentityId:)` — + * the payment list on the contact-detail screen shows only the + * history with that one contact. + */ + @Query( + "SELECT * FROM dashpay_payments WHERE ownerIdentityId = :ownerIdentityId " + + "AND counterpartyIdentityId = :counterpartyIdentityId" + ) + fun observePayments( + ownerIdentityId: ByteArray, + counterpartyIdentityId: ByteArray, + ): Flow> + + @Upsert + suspend fun upsertPayments(payments: List) + + /** Restore-path read: every payment row owned by [ownerIdentityId]. */ + @Query("SELECT * FROM dashpay_payments WHERE ownerIdentityId = :ownerIdentityId") + suspend fun getPaymentsByOwner(ownerIdentityId: ByteArray): List + + @Query("DELETE FROM dashpay_payments") + suspend fun deleteAllPayments() + + /** StorageExplorer row count. */ + @Query("SELECT COUNT(*) FROM dashpay_payments") + fun countPayments(): Flow + + /** StorageExplorer network-scoped row count. */ + @Query("SELECT COUNT(*) FROM dashpay_payments WHERE networkRaw = :networkRaw") + fun countPaymentsByNetwork(networkRaw: Int): Flow } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt new file mode 100644 index 00000000000..bb72b816457 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt @@ -0,0 +1,80 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import java.util.Date + +/** + * Port of `PersistentDashpayContactProfile.swift` — one cached DashPay + * **contact** profile, a mirror of one entry in the Rust-side + * `contact_profiles` map on a `ManagedIdentity`, one row per + * `(network, owner, contact)` triple. + * + * Distinct from [DashpayProfileEntity], which is the owner's *own* + * profile: this row is a *contact's* public profile, cached so the + * requests / contacts UI can show a display name + avatar without + * re-fetching on every launch. It holds **only the public profile + * fields** parsed from the on-chain `profile` document; it must never + * receive anything derived from the encrypted `contactInfo` path. + * + * Populated by the platform-wallet persister callback whenever an + * `IdentityEntry.contact_profiles` entry rides on the FFI changeset. A + * present profile (`is_present == true`) upserts this row; a + * confirmed-absent entry (`is_present == false`) DELETEs it, so a + * contact who removed their on-chain profile can't leave a stale + * name/avatar behind. Read back at load to rebuild the Rust + * `contact_profiles` map so the cache survives relaunch. + * + * [ownerIdentityId] materializes the NON-optional `owner` relationship + * with CASCADE (Swift `PersistentIdentity.contactProfiles` declares + * `.cascade`). + */ +@Entity( + tableName = "dashpay_contact_profiles", + primaryKeys = ["networkRaw", "ownerIdentityId", "contactIdentityId"], + indices = [Index(value = ["ownerIdentityId"])], + foreignKeys = [ + ForeignKey( + entity = IdentityEntity::class, + parentColumns = ["identityId"], + childColumns = ["ownerIdentityId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class DashpayContactProfileEntity( + /** `Network.rawValue`; Swift `UInt32` → [Int]. */ + val networkRaw: Int, + /** Owning (wallet-managed) identity's 32-byte id. */ + val ownerIdentityId: ByteArray, + /** The contact's 32-byte identity id — the `contact_profiles` map key. */ + val contactIdentityId: ByteArray, + /** `displayName` field on the contact's `profile` document. */ + val displayName: String? = null, + /** `publicMessage` field on the contact's `profile` document. */ + val publicMessage: String? = null, + /** Reserved forwards-compat slot (not in the v3 contract). */ + val bio: String? = null, + /** + * `avatarUrl` field — URL the consumer fetches + caches locally; the + * binary asset itself is never persisted. Untrusted public data: the + * Rust side caches and restores it only when it is a bounded + * `https://` URL. + */ + val avatarUrl: String? = null, + /** 32-byte hash of the avatar binary. */ + val avatarHash: ByteArray? = null, + /** 8-byte perceptual hash. */ + val avatarFingerprint: ByteArray? = null, + /** + * Wall-clock ms of the last fetch attempt on the Rust side + * (`ContactProfileEntry.checked_at_ms`) — drives the self-heal + * backoff. Round-tripped verbatim so the restored cache keeps the + * same re-query schedule it had before relaunch. Swift `UInt64` → + * [Long]. + */ + val checkedAtMs: Long, + val createdAt: Date = Date(), + val lastUpdated: Date = Date(), +) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt new file mode 100644 index 00000000000..81112e29561 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayPaymentEntity.kt @@ -0,0 +1,67 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import java.util.Date + +/** + * Port of `PersistentDashpayPayment.swift` — one DashPay payment-history + * entry, a mirror of one entry in the Rust-side `dashpay_payments` map on + * a `ManagedIdentity`, one row per `(network, owner, txid)` triple. + * + * Unlike the contact-request rows this entity is **not** populated by the + * persister callback — the Rust persister doesn't project payment + * history. Rows are refreshed on demand from the + * `managed_identity_get_dashpay_payments` FFI getter via + * `PlatformWalletManager.refreshDashPayPayments`, which upserts here so + * the UI can observe payments reactively. That refresh is the ONLY path + * by which payment rows become durable; the recurring DashPay sweep + * reconciles payments in-memory without persisting them. + * + * The source `PaymentEntry` carries no timestamp field (history is keyed + * by txid, no wall-clock time recorded), so none is persisted — + * [createdAt] / [lastUpdated] are local row bookkeeping, not payment + * dates. + * + * [ownerIdentityId] materializes the NON-optional `owner` relationship + * with CASCADE (Swift `PersistentIdentity.dashpayPayments` declares + * `.cascade`). + */ +@Entity( + tableName = "dashpay_payments", + primaryKeys = ["networkRaw", "ownerIdentityId", "txid"], + indices = [Index(value = ["ownerIdentityId"])], + foreignKeys = [ + ForeignKey( + entity = IdentityEntity::class, + parentColumns = ["identityId"], + childColumns = ["ownerIdentityId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class DashpayPaymentEntity( + /** `Network.rawValue`; Swift `UInt32` → [Int]. */ + val networkRaw: Int, + /** Owning (wallet-managed) identity's 32-byte id. */ + val ownerIdentityId: ByteArray, + /** + * The other identity in this payment + * (`DashpayPaymentFFI::counterparty_id`). Whether they are the sender + * or the receiver is encoded in [directionRaw]. + */ + val counterpartyIdentityId: ByteArray, + /** Amount in duffs; always positive, [directionRaw] carries the sign. Swift `UInt64` → [Long]. */ + val amountDuffs: Long, + /** Raw `DashPayPaymentDirection` value (0 = sent, 1 = received); Swift `UInt8` → [Int]. */ + val directionRaw: Int, + /** Raw `DashPayPaymentStatus` value (0 = pending, 1 = confirmed, 2 = failed); Swift `UInt8` → [Int]. */ + val statusRaw: Int, + /** Transaction id (hex), the Rust `dashpay_payments` map key. */ + val txid: String, + /** Sender memo, when present; null mirrors the source `Option` being `None`. */ + val memo: String? = null, + val createdAt: Date = Date(), + val lastUpdated: Date = Date(), +) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 030b659fb4a..ae657ace94a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -4,6 +4,7 @@ import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.TokensNative /** @@ -251,6 +252,92 @@ class Dashpay internal constructor(private val walletHandle: Long) { } } + // ── DashPay read surface (upstream #3841 parity) ─────────────────── + // + // JSON-string reads over the managed-identity snapshot, following the + // [getProfile] precedent (parsing happens at the consumer); see + // `DashpayNative` for the field shapes. + + /** + * Read [identityId]'s DashPay payment history as a JSON array string + * (empty array when none). Local read over a managed-identity + * snapshot — no network I/O. This getter is the ONLY durable source + * of payment rows: `PlatformWalletManager.refreshDashPayPayments` + * upserts its result into Room (the recurring sweep reconciles + * payments in-memory without persisting). Returns null when the + * identity isn't managed by this wallet. + * ← Swift `ManagedIdentity.getDashPayPayments()`. + */ + suspend fun payments(identityId: ByteArray): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(identityId) { handle -> + DashpayNative.managedIdentityDashPayPayments(handle) + } + } + } + + /** + * Read [identityId]'s DashPay sync state (collection counts + + * high-water cursors) as a JSON object string, or null when the + * identity isn't managed by this wallet. Local read. + * ← Swift `ManagedIdentity.getDashPaySyncState()`. + */ + suspend fun syncState(identityId: ByteArray): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(identityId) { handle -> + DashpayNative.managedIdentityDashPaySyncState(handle) + } + } + } + + /** + * Read the cached public profile of [contactIdentityId] as seen by + * [ownerIdentityId] (the contact-profile cache the requests/contacts + * UI renders names + avatars from), or null when none is cached. + * Same JSON shape as [getProfile]. Local read. + * ← Swift `ManagedPlatformWallet.getContactProfile(owner:contact:)`. + */ + suspend fun getContactProfile( + ownerIdentityId: ByteArray, + contactIdentityId: ByteArray, + ): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.getContactProfile(walletHandle, ownerIdentityId, contactIdentityId) + } + } + + /** + * Live DPNS prefix search against Platform (wallet-scoped — the call + * path iOS `AddContactView` drives). Returns a JSON array string of + * `{"label":…,"identityId":…hex}`; [limit] 0 means no limit. + * Blocking network call; runs on IO. + * ← Swift `ManagedPlatformWallet.searchDpnsNames(prefix:limit:)`. + */ + suspend fun searchDpnsNames(prefix: String, limit: Int = 10): String? = + withContext(Dispatchers.IO) { + require(limit >= 0) { "limit must be non-negative, got $limit" } + mapNativeErrors { DashpayNative.searchDpnsNames(walletHandle, prefix, limit) } + } + + /** + * Open the managed-identity handle for [identityId], run [block], + * and destroy the handle before returning (the [contacts] / + * [acceptIncomingRequest] discipline). Returns null when the + * identity isn't managed by this wallet. + */ + private inline fun withManagedIdentity( + identityId: ByteArray, + block: (Long) -> T, + ): T? { + val handle = TokensNative.getManagedIdentity(walletHandle, identityId) + if (handle == 0L) return null + return try { + block(handle) + } finally { + TokensNative.managedIdentityDestroy(handle) + } + } + private fun decodeIdBlob(blob: ByteArray?): List { if (blob == null || blob.size < 4) return emptyList() val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 9ac7ee50a10..71119c71819 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -19,12 +19,15 @@ import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.Sdk import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.FundingNative import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.persistence.DashDatabase import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.toHex +import org.json.JSONArray import org.dashfoundation.dashsdk.security.BiometricGate import org.dashfoundation.dashsdk.security.IdentityKeyPrivateKeyDeriver import org.dashfoundation.dashsdk.security.KeystoreSigner @@ -518,6 +521,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) } } 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 e1d652be553..8f5955414a3 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 @@ -759,6 +759,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/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 644d1728229..c1fc96100cf 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -141,15 +141,15 @@ pub struct IdentityEntryFFI { /// [`free_identity_entry_ffi`]. Ignore unless /// [`Self::dashpay_profile_present`] is `true`. pub dashpay_profile_public_message: *const c_char, - /// Heap-allocated array of [`ContactProfileRowFFI`], one per - /// **present** cached contact profile on the underlying - /// [`IdentityEntry::contact_profiles`]. Confirmed-absent entries - /// (`profile: None`, the negative cache) are NOT projected — they - /// rebuild harmlessly on the next sync sweep, so persisting them - /// would only add write churn. Each row owns the same per-string - /// heap allocations the own-profile block does; every string plus - /// the outer boxed slice is released in [`free_identity_entry_ffi`]. - /// `null` when [`Self::contact_profiles_count`] is 0. + /// Heap-allocated array of [`ContactProfileRowFFI`], one per entry + /// of the underlying [`IdentityEntry::contact_profiles`] map — + /// present profiles as full rows, confirmed-absent entries as + /// `is_present == false` tombstone rows instructing the consumer to + /// DELETE its persisted row (see [`ContactProfileRowFFI`]). Each row + /// owns the same per-string heap allocations the own-profile block + /// does; every string plus the outer boxed slice is released in + /// [`free_identity_entry_ffi`]. `null` when + /// [`Self::contact_profiles_count`] is 0. /// /// Distinct from `dashpay_profile_*` above: that block is the /// owner's *own* profile (one per identity); this array is the @@ -572,14 +572,12 @@ fn allocate_dpns_arrays( /// Allocate the [`ContactProfileRowFFI`] array carried on /// [`IdentityEntryFFI`] from the source /// [`IdentityEntry::contact_profiles`] map. Returns `(rows, count)` — -/// both `null`/`0` when no entry carries a **present** profile. -/// -/// **Present profiles only.** Confirmed-absent entries -/// (`ContactProfileEntry::profile == None`, the negative cache) are -/// skipped: they rebuild harmlessly on the next sync sweep, so -/// persisting them would only add write churn (the "persist only on -/// change" discipline). The returned `count` -/// is therefore the number of *present* profiles, not the map length. +/// both `null`/`0` when the map is empty. Every map entry produces a +/// row: present profiles as full rows, confirmed-absent entries +/// (`ContactProfileEntry::profile == None`) as `is_present == false` +/// tombstones that tell the consumer to DELETE its persisted row (a +/// contact who removed their profile must not keep showing a stale +/// name/avatar). `count` is therefore the map length. /// /// `rows` is a `Box<[ContactProfileRowFFI]>` (via [`Box::into_raw`]). /// Each row's four nullable C-strings are [`CString::into_raw`] diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs new file mode 100644 index 00000000000..d8f17bcefc6 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -0,0 +1,413 @@ +//! JNI bridge for the DashPay read surface added by the iOS DashPay +//! completion (upstream #3841): payment history, cached contact profiles, +//! per-identity sync state, wallet-scoped DPNS search and per-account +//! wallet balances. +//! +//! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DashpayNative`, +//! driven by `org.dashfoundation.dashsdk.tokens.Dashpay` and +//! `org.dashfoundation.dashsdk.wallet.PlatformWalletManager`. The 17 +//! pre-existing DashPay exports (send/accept/ignore/sync pipeline) live +//! in [`crate::tokens`]; new DashPay exports land here. +//! +//! ## Result convention +//! +//! Same as [`crate::tokens`]: every platform-wallet call returns +//! `PlatformWalletFFIResult` consumed Rust-side; +//! [`crate::support::take_pwffi_error`] maps failures to a thrown +//! `DashSDKException`. Read results surface as compact JSON strings +//! (the `getDashPayProfile` precedent — parsing happens Kotlin-side, +//! keeping descriptors trivial); byte ids are lower-hex. + +use crate::support::{guard, take_pwffi_error}; +use jni::objects::{JByteArray, JClass, JString}; +use jni::sys::{jint, jlong, jstring}; +use jni::JNIEnv; +use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; +use platform_wallet_ffi::handle::Handle; +use std::ffi::CStr; +use std::os::raw::c_char; +use std::ptr; + +fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + let len = env.get_array_length(arr).ok()? as usize; + if len != 32 { + let _ = env.throw_new( + "org/dashfoundation/dashsdk/ffi/DashSDKException", + format!("{field} must be 32 bytes, got {len}"), + ); + return None; + } + let mut buf = [0i8; 32]; + env.get_byte_array_region(arr, 0, &mut buf).ok()?; + Some(buf.map(|b| b as u8)) +} + +/// Render an optional FFI C-string as an owned Rust string. +/// +/// # Safety +/// `ptr`, when non-null, must be a valid NUL-terminated C string. +unsafe fn opt_cstr(ptr: *const c_char) -> Option { + if ptr.is_null() { + None + } else { + Some(CStr::from_ptr(ptr).to_string_lossy().into_owned()) + } +} + +/// Minimal JSON string escaping for the fields emitted here (same rules +/// as `tokens::profile_to_json`). +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn hex32(bytes: &[u8; 32]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn new_jstring(env: &mut JNIEnv, s: String) -> jstring { + env.new_string(s) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) +} + +// ── Payment history ─────────────────────────────────────────────────── + +/// Read the DashPay payment history off a managed-identity handle +/// (bridges `managed_identity_get_dashpay_payments`). Returns a JSON +/// array string — one object per payment: `txid`, `counterpartyId` +/// (lower-hex 32 bytes), `amountDuffs`, `direction` (0 Sent, 1 Received), +/// `status` (0 Pending, 1 Confirmed, 2 Failed), optional `memo`. The +/// Rust-owned array is freed here via `dashpay_payment_array_free`. +/// +/// This getter is the ONLY durable source of payment rows: the Kotlin +/// refresh path (`PlatformWalletManager.refreshDashPayPayments`) upserts +/// its result into Room, mirroring iOS — the recurring DashPay sweep +/// reconciles payments in-memory without persisting them. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityDashPayPayments( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut array = platform_wallet_ffi::DashpayPaymentArray { + items: ptr::null_mut(), + count: 0, + }; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_dashpay_payments( + identity_handle as Handle, + &mut array as *mut platform_wallet_ffi::DashpayPaymentArray, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(array.count); + if !array.items.is_null() && array.count > 0 { + let items = unsafe { std::slice::from_raw_parts(array.items, array.count) }; + for p in items { + let mut fields: Vec = Vec::with_capacity(6); + if let Some(txid) = unsafe { opt_cstr(p.txid) } { + fields.push(format!("\"txid\":{}", json_string(&txid))); + } + fields.push(format!( + "\"counterpartyId\":{}", + json_string(&hex32(&p.counterparty_id)) + )); + fields.push(format!("\"amountDuffs\":{}", p.amount_duffs)); + fields.push(format!("\"direction\":{}", p.direction as u8)); + fields.push(format!("\"status\":{}", p.status as u8)); + if let Some(memo) = unsafe { opt_cstr(p.memo) } { + fields.push(format!("\"memo\":{}", json_string(&memo))); + } + rows.push(format!("{{{}}}", fields.join(","))); + } + } + unsafe { + platform_wallet_ffi::dashpay_payment_array_free( + &mut array as *mut platform_wallet_ffi::DashpayPaymentArray, + ) + }; + new_jstring(env, format!("[{}]", rows.join(","))) + }) +} + +// ── Profiles ────────────────────────────────────────────────────────── + +/// Read the cached profile of `contactIdentityId` as seen by +/// `ownerIdentityId` (bridges `platform_wallet_get_contact_profile`). +/// Returns the same JSON object shape as `TokensNative.getDashPayProfile` +/// or null when no present profile is cached. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_getContactProfile( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + owner_identity_id: JByteArray, + contact_identity_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_id32(env, &owner_identity_id, "ownerIdentityId") else { + return ptr::null_mut(); + }; + let Some(contact) = read_id32(env, &contact_identity_id, "contactIdentityId") else { + return ptr::null_mut(); + }; + let mut profile = DashPayProfileFFI::empty(); + let mut has_profile = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_contact_profile( + wallet_handle as Handle, + owner.as_ptr(), + contact.as_ptr(), + &mut profile as *mut DashPayProfileFFI, + &mut has_profile as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if !has_profile { + return ptr::null_mut(); + } + let json = crate::tokens::profile_to_json(&profile); + unsafe { + platform_wallet_ffi::dashpay_profile_ffi_free(&mut profile as *mut DashPayProfileFFI) + }; + new_jstring(env, json) + }) +} + +/// Read the managed identity's own cached DashPay profile off its handle +/// (bridges `managed_identity_get_dashpay_profile`). Same JSON shape / +/// null convention as [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_getContactProfile`]. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityDashPayProfile( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut profile = DashPayProfileFFI::empty(); + let mut has_profile = false; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_dashpay_profile( + identity_handle as Handle, + &mut profile as *mut DashPayProfileFFI, + &mut has_profile as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if !has_profile { + return ptr::null_mut(); + } + let json = crate::tokens::profile_to_json(&profile); + unsafe { + platform_wallet_ffi::dashpay_profile_ffi_free(&mut profile as *mut DashPayProfileFFI) + }; + new_jstring(env, json) + }) +} + +// ── Sync state ──────────────────────────────────────────────────────── + +/// Read the managed identity's DashPay sync state (bridges +/// `managed_identity_get_dashpay_sync_state`). Returns a JSON object of +/// the collection counts + high-water cursors; the optional cursors omit +/// their key when unset (mirroring the Swift `DashPaySyncState` optionals). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityDashPaySyncState( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut state = platform_wallet_ffi::dashpay_profile::DashPaySyncStateFFI { + established_contacts: 0, + incoming_requests: 0, + sent_requests: 0, + ignored_senders: 0, + contact_profiles: 0, + present_contact_profiles: 0, + dashpay_payments: 0, + has_dashpay_profile: false, + has_high_water_received: false, + high_water_received_ms: 0, + has_high_water_sent: false, + high_water_sent_ms: 0, + }; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_dashpay_sync_state( + identity_handle as Handle, + &mut state as *mut platform_wallet_ffi::dashpay_profile::DashPaySyncStateFFI, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut fields = vec![ + format!("\"establishedContacts\":{}", state.established_contacts), + format!("\"incomingRequests\":{}", state.incoming_requests), + format!("\"sentRequests\":{}", state.sent_requests), + format!("\"ignoredSenders\":{}", state.ignored_senders), + format!("\"contactProfiles\":{}", state.contact_profiles), + format!( + "\"presentContactProfiles\":{}", + state.present_contact_profiles + ), + format!("\"dashpayPayments\":{}", state.dashpay_payments), + format!("\"hasDashpayProfile\":{}", state.has_dashpay_profile), + ]; + if state.has_high_water_received { + fields.push(format!( + "\"highWaterReceivedMs\":{}", + state.high_water_received_ms + )); + } + if state.has_high_water_sent { + fields.push(format!("\"highWaterSentMs\":{}", state.high_water_sent_ms)); + } + new_jstring(env, format!("{{{}}}", fields.join(","))) + }) +} + +// ── Wallet-scoped DPNS search ───────────────────────────────────────── + +/// Live DPNS prefix search against Platform, wallet-scoped (bridges +/// `platform_wallet_search_dpns_names` — the call path the iOS +/// `AddContactView` drives; distinct from the SDK-handle-scoped +/// `QueriesNative.dpnsSearch`). Returns a JSON array of +/// `{"label":…,"identityId":…hex}` rows; `limit == 0` means no limit. +/// Blocking (network). The Rust-owned results are freed here via +/// `dpns_search_results_free`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_searchDpnsNames( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + prefix: JString, + limit: jint, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let prefix_str: String = match env.get_string(&prefix) { + Ok(s) => s.into(), + Err(_) => return ptr::null_mut(), + }; + let Ok(prefix_c) = std::ffi::CString::new(prefix_str) else { + return ptr::null_mut(); + }; + let mut results: *mut platform_wallet_ffi::DpnsSearchResultFFI = ptr::null_mut(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_search_dpns_names( + wallet_handle as Handle, + prefix_c.as_ptr(), + limit.max(0) as u32, + &mut results as *mut *mut platform_wallet_ffi::DpnsSearchResultFFI, + &mut count as *mut usize, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(count); + if !results.is_null() && count > 0 { + let items = unsafe { std::slice::from_raw_parts(results, count) }; + for r in items { + let label = unsafe { opt_cstr(r.label) }.unwrap_or_default(); + rows.push(format!( + "{{\"label\":{},\"identityId\":{}}}", + json_string(&label), + json_string(&hex32(&r.identity_id)) + )); + } + } + unsafe { platform_wallet_ffi::dpns_search_results_free(results, count) }; + new_jstring(env, format!("[{}]", rows.join(","))) + }) +} + +// ── Per-account wallet balances ─────────────────────────────────────── + +/// Read the per-account balance snapshot for a wallet off the manager +/// (bridges `platform_wallet_manager_get_account_balances` — drives the +/// iOS DashPay tab's account balance display). Returns a JSON array — +/// one object per account with the type tags, indices, identity ids +/// (lower-hex; all-zero when unset) and the four balance buckets. The +/// Rust-owned array is freed here. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletManagerAccountBalances( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let mut entries: *const platform_wallet_ffi::AccountBalanceEntryFFI = ptr::null(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_get_account_balances( + manager_handle as Handle, + wid.as_ptr(), + &mut entries as *mut *const platform_wallet_ffi::AccountBalanceEntryFFI, + &mut count as *mut usize, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(count); + if !entries.is_null() && count > 0 { + let items = unsafe { std::slice::from_raw_parts(entries, count) }; + for e in items { + rows.push(format!( + "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ + \"registrationIndex\":{},\"keyClass\":{},\ + \"userIdentityId\":{},\"friendIdentityId\":{},\ + \"confirmed\":{},\"unconfirmed\":{},\"immature\":{},\ + \"locked\":{},\"keysUsed\":{},\"keysTotal\":{}}}", + e.type_tag as u8, + e.standard_tag as u8, + e.index, + e.registration_index, + e.key_class, + json_string(&hex32(&e.user_identity_id)), + json_string(&hex32(&e.friend_identity_id)), + e.confirmed, + e.unconfirmed, + e.immature, + e.locked, + e.keys_used, + e.keys_total, + )); + } + } + unsafe { + platform_wallet_ffi::platform_wallet_manager_free_account_balances( + entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, + count, + ) + }; + new_jstring(env, format!("[{}]", rows.join(","))) + }) +} diff --git a/packages/rs-unified-sdk-jni/src/lib.rs b/packages/rs-unified-sdk-jni/src/lib.rs index 3c9e50962df..d614fb7132d 100644 --- a/packages/rs-unified-sdk-jni/src/lib.rs +++ b/packages/rs-unified-sdk-jni/src/lib.rs @@ -15,6 +15,7 @@ //! - Rust→Kotlin callbacks attach their (possibly Tokio) thread as a daemon. mod credits; +mod dashpay; mod events; mod funding; mod identity; diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index ff2d3144419..493a9af7160 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -52,11 +52,12 @@ use jni::sys::jlong; use jni::JNIEnv; use platform_wallet_ffi::{ AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI, - AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, - CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, - IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, PersistenceCallbacks, PlatformAddressFFI, - SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UtxoEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, + AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, + ContactRequestRemovalFFI, CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, + IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, PaymentRestoreEntryFFI, + PersistenceCallbacks, PlatformAddressFFI, SpentOutPointFFI, TokenBalanceRemovalFFI, + TokenBalanceUpsertFFI, TransactionRecordFFI, UtxoEntryFFI, WalletChangeSetFFI, + WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -863,35 +864,84 @@ unsafe fn persist_identity_upsert( let avatar_hash = env.byte_array_from_slice(&e.dashpay_profile_avatar_hash)?; let avatar_fp = env.byte_array_from_slice(&e.dashpay_profile_avatar_fingerprint)?; - env.call_method( - bridge, - "onPersistIdentityUpsert", - "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ - Ljava/lang/String;[BZ[BZLjava/lang/String;)I", - &[ - wid.into(), - (&identity_id).into(), - JValue::Long(e.balance as i64), - JValue::Long(e.revision as i64), - JValue::Bool(e.identity_index_is_some as u8), - JValue::Int(e.identity_index as i32), - JValue::Byte(e.status as i8), - JValue::Bool(e.wallet_id_is_some as u8), - (&identity_wallet_id).into(), - (&dpns_arr).into(), - (&acquired_arr).into(), - JValue::Bool(e.dashpay_profile_present as u8), - (&display).into(), - (&bio).into(), - (&avatar_url).into(), - (&avatar_hash).into(), - JValue::Bool(e.dashpay_profile_avatar_hash_present as u8), - (&avatar_fp).into(), - JValue::Bool(e.dashpay_profile_avatar_fingerprint_present as u8), - (&public_message).into(), - ], - )? - .i() + let code = env + .call_method( + bridge, + "onPersistIdentityUpsert", + "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ + Ljava/lang/String;[BZ[BZLjava/lang/String;)I", + &[ + wid.into(), + (&identity_id).into(), + JValue::Long(e.balance as i64), + JValue::Long(e.revision as i64), + JValue::Bool(e.identity_index_is_some as u8), + JValue::Int(e.identity_index as i32), + JValue::Byte(e.status as i8), + JValue::Bool(e.wallet_id_is_some as u8), + (&identity_wallet_id).into(), + (&dpns_arr).into(), + (&acquired_arr).into(), + JValue::Bool(e.dashpay_profile_present as u8), + (&display).into(), + (&bio).into(), + (&avatar_url).into(), + (&avatar_hash).into(), + JValue::Bool(e.dashpay_profile_avatar_hash_present as u8), + (&avatar_fp).into(), + JValue::Bool(e.dashpay_profile_avatar_fingerprint_present as u8), + (&public_message).into(), + ], + )? + .i()?; + if code != 0 { + return Ok(code); + } + + // Cached contact profiles riding the identity entry + // (`IdentityEntryFFI::contact_profiles`): one bridge call per row so + // the flat args stay manageable, mirroring the per-delta contacts + // callbacks. `is_present == true` upserts the Room row; + // `is_present == false` is a tombstone — the contact removed their + // on-chain profile and the persisted row must be DELETED (an + // upsert-only pipeline would show the stale name/avatar forever). + for row in slice_or_empty(e.contact_profiles, e.contact_profiles_count) { + let code = env.with_local_frame(16, |env| { + let contact_id = env.byte_array_from_slice(&row.contact_id)?; + let display = cstr_opt(env, row.display_name)?; + let bio = cstr_opt(env, row.bio)?; + let avatar_url = cstr_opt(env, row.avatar_url)?; + let public_message = cstr_opt(env, row.public_message)?; + let avatar_hash = env.byte_array_from_slice(&row.avatar_hash)?; + let avatar_fp = env.byte_array_from_slice(&row.avatar_fingerprint)?; + env.call_method( + bridge, + "onPersistContactProfileDelta", + "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ + [BZ[BZLjava/lang/String;J)I", + &[ + wid.into(), + (&identity_id).into(), + (&contact_id).into(), + JValue::Bool(row.is_present as u8), + (&display).into(), + (&bio).into(), + (&avatar_url).into(), + (&avatar_hash).into(), + JValue::Bool(row.avatar_hash_present as u8), + (&avatar_fp).into(), + JValue::Bool(row.avatar_fingerprint_present as u8), + (&public_message).into(), + JValue::Long(row.checked_at_ms as i64), + ], + )? + .i() + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) } // ── Identity keys ───────────────────────────────────────────────────── @@ -1471,23 +1521,45 @@ struct AccountSpecStaged { } /// Staged identity-restore row: FFI struct with `keys` / `contacts` / -/// `ignored_senders` still null / 0 until sealed, plus the owned per-key -/// and per-contact staging. `dpns_names` / `contested_dpns_names` are left -/// null / 0 this pass (not yet ported), and `payments` / -/// `contact_profiles` stay null / 0 because Kotlin has no persist source -/// for them yet (no Room analog of `PersistentDashpayPayment` / -/// `PersistentDashpayContactProfile`), so there is nothing owned for any -/// of those to free. +/// `ignored_senders` / `payments` / `contact_profiles` still null / 0 +/// until sealed, plus the owned per-row staging. `dpns_names` / +/// `contested_dpns_names` are left null / 0 this pass (not yet ported). struct IdentityRestoreStaged { - /// FFI entry with `keys` / `contacts` / `ignored_senders` (and the - /// dpns / payments / contact-profile arrays) still null / 0 until - /// sealed. + /// FFI entry with the nested arrays still null / 0 until sealed. entry: IdentityRestoreEntryFFI, keys: Vec, contacts: Vec, /// Bare 32-byte ignored-sender ids (per-sender mute) — POD, minted /// as a flat `[u8; 32]` array at seal. ignored_senders: Vec<[u8; 32]>, + payments: Vec, + contact_profiles: Vec, +} + +/// Staged DashPay payment-history row: FFI struct with `txid` / `memo` +/// still null until sealed, plus the owned strings that back them. +/// Rehydrates the managed identity's `dashpay_payments` map — without it +/// *Sent* entries (and their memos) vanish on every relaunch because the +/// reconcile sweep can only re-derive *Received* entries from UTXOs. +struct PaymentRestoreStaged { + /// FFI row with `txid` / `memo` still null until sealed. + row: PaymentRestoreEntryFFI, + /// The txid map key. Always present on a persisted row. + txid: CString, + memo: Option, +} + +/// Staged cached contact-profile row: FFI struct with the four optional +/// c-strings still null until sealed, plus the owned strings. Only +/// **present** profiles are persisted (tombstones delete the Room row), +/// so every staged row rehydrates as a present cache entry. +struct ContactProfileRestoreStaged { + /// FFI row with the string fields still null until sealed. + row: ContactProfileRestoreEntryFFI, + display_name: Option, + bio: Option, + avatar_url: Option, + public_message: Option, } /// Staged DashPay contact-restore row: FFI struct with every pointer @@ -1554,6 +1626,8 @@ fn seal_wallet_entries(staged: Vec) -> Vec = keys .into_iter() @@ -1609,6 +1683,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 }, ) @@ -1824,6 +1937,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, @@ -1838,13 +1996,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(), @@ -1857,6 +2008,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, }) } @@ -2041,14 +2279,15 @@ unsafe extern "C" fn tramp_load_wallet_list_free( drop(specs); } - // identities + nested key / contact / ignored-sender arrays - // (each key's `data` buffer + contract-bounds doc-type C-string; - // each contact's three byte payloads, three metadata C-strings - // and `accepted_accounts` u32 buffer). Mirrors exactly what + // identities + nested key / contact / ignored-sender / + // payment / contact-profile arrays (each key's `data` buffer + + // contract-bounds doc-type C-string; each contact's three byte + // payloads, three metadata C-strings and `accepted_accounts` + // u32 buffer; each payment's txid/memo C-strings; each contact + // profile's four optional C-strings). Mirrors exactly what // `seal_wallet_entries` minted. The dpns_names / - // contested_dpns_names / payments / contact_profiles arrays are - // never minted this pass (staged null / 0), so there is nothing - // to reclaim for them. + // contested_dpns_names arrays are never minted this pass + // (staged null / 0), so there is nothing to reclaim for them. if !e.identities.is_null() && e.identities_count > 0 { let idents: Box<[IdentityRestoreEntryFFI]> = Box::from_raw(std::ptr::slice_from_raw_parts_mut( @@ -2095,6 +2334,32 @@ unsafe extern "C" fn tramp_load_wallet_list_free( // Flat POD array of 32-byte sender ids — no nested // buffers, just the boxed slice itself. free_raw_slice(ident.ignored_senders, ident.ignored_senders_count); + if !ident.payments.is_null() && ident.payments_count > 0 { + let payments: Box<[PaymentRestoreEntryFFI]> = + Box::from_raw(std::ptr::slice_from_raw_parts_mut( + ident.payments as *mut PaymentRestoreEntryFFI, + ident.payments_count, + )); + for p in payments.iter() { + free_raw_cstring(p.txid); + free_raw_cstring(p.memo); + } + drop(payments); + } + if !ident.contact_profiles.is_null() && ident.contact_profiles_count > 0 { + let profiles: Box<[ContactProfileRestoreEntryFFI]> = + Box::from_raw(std::ptr::slice_from_raw_parts_mut( + ident.contact_profiles as *mut ContactProfileRestoreEntryFFI, + ident.contact_profiles_count, + )); + for cp in profiles.iter() { + free_raw_cstring(cp.display_name); + free_raw_cstring(cp.bio); + free_raw_cstring(cp.avatar_url); + free_raw_cstring(cp.public_message); + } + drop(profiles); + } } drop(idents); } diff --git a/packages/rs-unified-sdk-jni/src/tokens.rs b/packages/rs-unified-sdk-jni/src/tokens.rs index 0781b33fe24..800409f4211 100644 --- a/packages/rs-unified-sdk-jni/src/tokens.rs +++ b/packages/rs-unified-sdk-jni/src/tokens.rs @@ -1460,11 +1460,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TokensNative_getDashP } /// Render a [`DashPayProfileFFI`] as a compact JSON object string. Optional -/// scalars omit their key when absent; byte fields are lower-hex. +/// scalars omit their key when absent; byte fields are lower-hex. Shared +/// with [`crate::dashpay`]'s profile readers so both surfaces emit the +/// same shape. /// /// # Safety /// `profile`'s string pointers, when non-null, must be valid C strings. -fn profile_to_json(profile: &DashPayProfileFFI) -> String { +pub(crate) fn profile_to_json(profile: &DashPayProfileFFI) -> String { fn opt_cstr(ptr: *const c_char) -> Option { if ptr.is_null() { None