From 3925887bf5a33cc747848187489cad27525a086f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 14:20:34 +0700 Subject: [PATCH 01/13] feat(platform-wallet): DPNS username-marketplace wallet layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet-level orchestration for the DPNS marketplace, composing the existing generic document-trade transitions with the DPNS specifics the app layer should not own: - DpnsDomainState queries keeping $id and $price (search by prefix with cursor pagination, exact-name state, per-identity states via the records.identity index) - set_dpns_name_price / delist_dpns_name (transfer-to-self, verified to clear $price on the confirmed document) / transfer_dpns_name / purchase_dpns_name with typed pre-flight checks and automatic AUTHENTICATION+ECDSA signing-key selection - typed errors (DpnsNameNotFound, DocumentNotForSale, DocumentPriceChanged, InsufficientIdentityCredits, ContestedNameNotTradable) incl. consensus-error downcasts (40108 / 40109 / IdentityInsufficientBalanceError) wired into the generic set-price/purchase/transfer paths - dpns_name_history: per-name Registered/PriceSet/Purchased/Transferred timeline from the Document History system contract (byDocument index) - DpnsNameStateEntry persistence: new changeset + capability bit DPNS_NAME_STATES + sqlite table (V005) + in-memory working set on PlatformWalletInfo; sold/transferred rows retained with counterparty - dpns_names label-list merge/apply switched from append-only-by-label to LWW wholesale (every emitter snapshots the full list) so sold names can leave; set_dpns_names/remove_dpns_name mutations added - DpnsSyncManager: periodic marketplace sweep (60s default) detecting price changes, acquisitions, and departures (sold vs transferred classified through the history contract), with quiesce/shutdown parity with the sibling coordinators and a completion event Design record + browse-for-sale ($price index) investigation: docs/DPNS_MARKETPLACE.md — a global browse-by-price query is not buildable at any layer today (protocol-upgrade path documented). Co-Authored-By: Claude Fable 5 --- .../migrations/V005__dpns_name_states.rs | 35 + .../src/sqlite/persister.rs | 4 + .../src/sqlite/schema/dpns_name_states.rs | 263 +++ .../src/sqlite/schema/mod.rs | 1 + .../docs/DPNS_MARKETPLACE.md | 263 +++ .../src/changeset/changeset.rs | 204 ++- .../rs-platform-wallet/src/changeset/mod.rs | 7 +- .../src/changeset/persistence_capabilities.rs | 7 + packages/rs-platform-wallet/src/error.rs | 129 ++ packages/rs-platform-wallet/src/events.rs | 23 + .../src/manager/accessors.rs | 12 + .../src/manager/dpns_sync.rs | 323 ++++ .../rs-platform-wallet/src/manager/load.rs | 1 + .../rs-platform-wallet/src/manager/mod.rs | 27 +- .../src/manager/wallet_lifecycle.rs | 1 + .../rs-platform-wallet/src/test_support.rs | 5 + .../rs-platform-wallet/src/wallet/apply.rs | 14 + .../src/wallet/asset_lock/sync/recovery.rs | 1 + .../identity/network/contact_requests.rs | 1 + .../src/wallet/identity/network/document.rs | 31 +- .../identity/network/dpns_marketplace.rs | 1440 +++++++++++++++++ .../src/wallet/identity/network/mod.rs | 5 + .../state/managed_identity/identity_ops.rs | 31 + .../wallet/identity/state/manager/apply.rs | 15 +- .../src/wallet/platform_wallet.rs | 6 + .../src/wallet/platform_wallet_traits.rs | 2 + 26 files changed, 2819 insertions(+), 32 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs create mode 100644 packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md create mode 100644 packages/rs-platform-wallet/src/manager/dpns_sync.rs create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs diff --git a/packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs b/packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs new file mode 100644 index 00000000000..09bf4cba0ec --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs @@ -0,0 +1,35 @@ +//! Add the `dpns_name_states` table (DPNS username marketplace). +//! +//! One row per tracked DPNS `domain` document belonging to (or recently +//! departed from) a wallet identity, carrying the sale state (`$price`) +//! the label-only `dpns_names` list on the identity blob cannot: document +//! id, listed price, ownership status, and the document's own timestamps. +//! Written by the marketplace sync pass and the set-price / delist / +//! purchase / transfer orchestration ops. +//! +//! All fields map to explicit columns (the entry is all-primitive), so no +//! opaque blob is needed — the row reconstructs directly. `counterparty_id` +//! carries the buyer/recipient for `sold` / `transferred` rows and is NULL +//! for `owned` rows (the status enum's payload flattened into a column). + +pub fn migration() -> String { + "CREATE TABLE dpns_name_states ( + wallet_id BLOB NOT NULL, + document_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + label TEXT NOT NULL, + normalized_label TEXT NOT NULL, + normalized_parent_domain TEXT NOT NULL, + price INTEGER, + status TEXT NOT NULL CHECK (status IN ('owned', 'sold', 'transferred')), + counterparty_id BLOB, + created_at_ms INTEGER, + updated_at_ms INTEGER, + transferred_at_ms INTEGER, + last_synced_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, document_id), + CHECK ((status = 'owned') = (counterparty_id IS NULL)), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + );" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 884319cab24..ef0890f4e8b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -829,6 +829,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) + .union(PersistenceCapabilities::DPNS_NAME_STATES) } /// Merge `changeset` into the per-wallet buffer. @@ -1105,6 +1106,9 @@ fn apply_changeset_to_tx( if let Some(invitations) = cs.invitations.as_ref() { schema::invitations::apply(tx, wallet_id, invitations)?; } + if let Some(dpns_name_states) = cs.dpns_name_states.as_ref() { + schema::dpns_name_states::apply(tx, wallet_id, dpns_name_states)?; + } if let Some(balances) = cs.token_balances.as_ref() { schema::token_balances::apply(tx, wallet_id, balances)?; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs new file mode 100644 index 00000000000..fa3f39b9c55 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs @@ -0,0 +1,263 @@ +//! `dpns_name_states` table writer + reader (DPNS username marketplace). +//! +//! Every field maps to an explicit column (the entry is all-primitive), so a +//! row reconstructs a [`DpnsNameStateEntry`] directly — no blob. The status +//! enum's `Sold { to } / Transferred { to }` payload is flattened into the +//! `counterparty_id` column (NULL for `owned`), with the pairing enforced by +//! a table CHECK. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +// Imports used only by the test-gated reader below. +#[cfg(any(test, feature = "__test-helpers"))] +use { + dpp::prelude::Identifier, platform_wallet::changeset::DpnsNameStateEntry, + rusqlite::Connection, std::collections::BTreeMap, +}; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &DpnsNameStateChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.names.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO dpns_name_states \ + (wallet_id, document_id, identity_id, label, normalized_label, \ + normalized_parent_domain, price, status, counterparty_id, \ + created_at_ms, updated_at_ms, transferred_at_ms, last_synced_at_ms) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \ + ON CONFLICT(wallet_id, document_id) DO UPDATE SET \ + identity_id = excluded.identity_id, \ + label = excluded.label, \ + normalized_label = excluded.normalized_label, \ + normalized_parent_domain = excluded.normalized_parent_domain, \ + price = excluded.price, \ + status = excluded.status, \ + counterparty_id = excluded.counterparty_id, \ + created_at_ms = excluded.created_at_ms, \ + updated_at_ms = excluded.updated_at_ms, \ + transferred_at_ms = excluded.transferred_at_ms, \ + last_synced_at_ms = excluded.last_synced_at_ms", + )?; + for (document_id, entry) in &cs.names { + let (status, counterparty) = status_columns(&entry.status); + let price = entry + .price + .map(|p| crate::sqlite::util::safe_cast::u64_to_i64("dpns_name_states.price", p)) + .transpose()?; + stmt.execute(params![ + wallet_id.as_slice(), + document_id.as_slice(), + entry.wallet_identity_id.as_slice(), + entry.label, + entry.normalized_label, + entry.normalized_parent_domain_name, + price, + status, + counterparty.map(|c| c.to_vec()), + entry.created_at_ms.map(|v| crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.created_at_ms", + v + )).transpose()?, + entry.updated_at_ms.map(|v| crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.updated_at_ms", + v + )).transpose()?, + entry.transferred_at_ms.map(|v| crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.transferred_at_ms", + v + )).transpose()?, + crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.last_synced_at_ms", + entry.last_synced_at_ms + )?, + ])?; + } + } + if !cs.removed.is_empty() { + let mut stmt = tx.prepare_cached( + "DELETE FROM dpns_name_states WHERE wallet_id = ?1 AND document_id = ?2", + )?; + for document_id in &cs.removed { + stmt.execute(params![wallet_id.as_slice(), document_id.as_slice()])?; + } + } + Ok(()) +} + +/// Single source of truth for the `dpns_name_states.status` TEXT-column +/// domain + counterparty flattening. The `CHECK (status IN …)` in +/// `migrations/V005__dpns_name_states.rs` must list exactly these values. +pub(crate) fn status_columns(s: &DpnsNameSaleStatus) -> (&'static str, Option<[u8; 32]>) { + match s { + DpnsNameSaleStatus::Owned => ("owned", None), + DpnsNameSaleStatus::Sold { to } => ("sold", Some(to.to_buffer())), + DpnsNameSaleStatus::Transferred { to } => ("transferred", Some(to.to_buffer())), + } +} + +#[cfg(any(test, feature = "__test-helpers"))] +fn status_from_columns( + status: &str, + counterparty: Option>, +) -> Result { + let to = || -> Result { + let bytes = counterparty + .as_deref() + .ok_or_else(|| WalletStorageError::blob_decode("missing counterparty_id for row"))?; + Identifier::from_bytes(bytes) + .map_err(|_| WalletStorageError::blob_decode("counterparty_id is not 32 bytes")) + }; + match status { + "owned" => Ok(DpnsNameSaleStatus::Owned), + "sold" => Ok(DpnsNameSaleStatus::Sold { to: to()? }), + "transferred" => Ok(DpnsNameSaleStatus::Transferred { to: to()? }), + _ => Err(WalletStorageError::blob_decode( + "unknown dpns_name_states.status value in row", + )), + } +} + +/// Read every DPNS name-state row for a wallet, keyed by document id. +/// Test/round-trip helper (the production load path does not re-hydrate +/// name states into the Rust manager; the Swift SwiftData mirror is the UI +/// source). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_all( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT document_id, identity_id, label, normalized_label, normalized_parent_domain, \ + price, status, counterparty_id, created_at_ms, updated_at_ms, \ + transferred_at_ms, last_synced_at_ms \ + FROM dpns_name_states WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, Option>>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, i64>(11)?, + )) + })?; + let mut out = BTreeMap::new(); + for row in rows { + let ( + doc_bytes, + identity_bytes, + label, + normalized_label, + normalized_parent, + price, + status, + counterparty, + created_at, + updated_at, + transferred_at, + last_synced, + ) = row?; + let document_id = Identifier::from_bytes(&doc_bytes) + .map_err(|_| WalletStorageError::blob_decode("document_id is not 32 bytes"))?; + let wallet_identity_id = Identifier::from_bytes(&identity_bytes) + .map_err(|_| WalletStorageError::blob_decode("identity_id is not 32 bytes"))?; + out.insert( + document_id, + DpnsNameStateEntry { + document_id, + wallet_identity_id, + label, + normalized_label, + normalized_parent_domain_name: normalized_parent, + price: price.map(|p| p as u64), + status: status_from_columns(&status, counterparty)?, + created_at_ms: created_at.map(|v| v as u64), + updated_at_ms: updated_at.map(|v| v as u64), + transferred_at_ms: transferred_at.map(|v| v as u64), + last_synced_at_ms: last_synced as u64, + }, + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(seed: u8, status: DpnsNameSaleStatus, price: Option) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([seed; 32]), + wallet_identity_id: Identifier::from([0xAA; 32]), + label: format!("Alice{seed}"), + normalized_label: format!("a11ce{seed}"), + normalized_parent_domain_name: "dash".to_string(), + price, + status, + created_at_ms: Some(1_700_000_000_000), + updated_at_ms: None, + transferred_at_ms: None, + last_synced_at_ms: 1_800_000_000_000, + } + } + + #[test] + fn apply_then_read_round_trips_and_upserts_and_removes() { + let wallet_id: WalletId = [0x22; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + // Insert two: one listed, one unlisted. + let e0 = entry(0, DpnsNameSaleStatus::Owned, Some(5_000_000_000)); + let e1 = entry(1, DpnsNameSaleStatus::Owned, None); + let mut cs = DpnsNameStateChangeSet::default(); + cs.names.insert(e0.document_id, e0.clone()); + cs.names.insert(e1.document_id, e1.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + tx.commit().unwrap(); + } + let got = read_all(&conn, &wallet_id).unwrap(); + assert_eq!(got.len(), 2); + assert_eq!(got[&e0.document_id], e0); + assert_eq!(got[&e1.document_id], e1); + + // Upsert e0 → sold (price cleared by consensus), remove e1. + let buyer = Identifier::from([0xBB; 32]); + let mut e0b = e0.clone(); + e0b.price = None; + e0b.status = DpnsNameSaleStatus::Sold { to: buyer }; + e0b.transferred_at_ms = Some(1_800_000_100_000); + let mut cs2 = DpnsNameStateChangeSet::default(); + cs2.names.insert(e0b.document_id, e0b.clone()); + cs2.removed.insert(e1.document_id); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs2).unwrap(); + tx.commit().unwrap(); + } + let got = read_all(&conn, &wallet_id).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[&e0.document_id], e0b); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 41b4d82c271..5335bde9943 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -19,6 +19,7 @@ pub mod blob; pub mod contacts; pub mod core_state; pub mod dashpay; +pub mod dpns_name_states; pub mod identities; pub mod identity_keys; pub mod invitations; diff --git a/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md b/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md new file mode 100644 index 00000000000..7740cbc6e5a --- /dev/null +++ b/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md @@ -0,0 +1,263 @@ +# DPNS Username Marketplace — wallet-level design + +Status: implementation in progress (2026-08-09). This document is the design +record for the wallet-level DPNS marketplace layer in `rs-platform-wallet`, +its FFI surface, and the swift-sdk wrappers. It also records the +browse-for-sale investigation result (§7), which is a protocol limitation the +wallet cannot work around. + +## 1. Scope + +The dashwallet-ios marketplace UI v1 (branch `feat/username-marketplace`) +composes the generic document-trade primitives directly +(`setDocumentPrice` / `purchaseDocument` / `transferDocument` + +`SDK.documentList`). This layer replaces that composition with durable +wallet-level operations the app can swap to without UI changes: + +| Conceptual op (app v1) | Wallet-level API | +|-------------------------------------|----------------------------------------------------| +| search names + sale state | `search_dpns_names_with_state(prefix, limit, start_after)` | +| my names (with sale state) | local `DpnsNameStateEntry` rows, refreshed by sync | +| authoritative single-name re-read | `dpns_name_state(label)` | +| set price | `set_dpns_name_price(identity, label, price, signer)` | +| delist | `delist_dpns_name(identity, label, signer)` (transfer-to-self) | +| purchase | `purchase_dpns_name(identity, label, expected_price, signer)` | +| gift transfer | `transfer_dpns_name(identity, label, recipient, signer)` | +| per-name history | `dpns_name_history(label)` (new capability — app had none) | + +Prices are **credits** everywhere in this layer (1 duff = 1000 credits). The +duffs↔credits conversion is a UI concern. + +## 2. On-chain semantics this design relies on (verified in source) + +- `domain` v2: `documentsMutable=false`, `canBeDeleted=true`, `transferable=1`, + `tradeMode=1`, all three `keeps*History` flags true. Indices: + `parentNameAndLabel` (unique) and `identityId` (`records.identity`). No + `$price` index (see §7). +- Purchase requires `$price` present (`DocumentNotForSaleError`, code 40108) + and the transition's price must equal the listed price + (`DocumentIncorrectPurchasePriceError`, code 40109 — carries both prices). +- **Both purchase and transfer remove `$price`** + (`document_purchase_transition_action/v0/transformer.rs`, + `document_transfer_transition_action/v0/transformer.rs`). Transfer-to-self + is therefore the delist primitive: ownership is unchanged, `$price` is + cleared by consensus. There is no dedicated "remove price" transition and + `documentsMutable=false` rules out a replace. +- **`records.identity` is rewritten to the new owner by the protocol** on + purchase and transfer (`rewrite_dpns_domain_identity_record_to_new_owner`, + `action_convert_to_operations` v1). So the `identityId` index stays + authoritative for "names associated with identity X" across sales, and a + seller's sync pass observes sold names dropping out of its query result. +- History events are written to the **Document History system contract** + (`6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD`), doc types `transfer`, + `purchase` (`sellerId`, `price`), `priceUpdate` (`price`), all with + `$createdAt`/`$createdAtBlockHeight` required and a `byDocument` + (dataContractId, documentId, $createdAt) index. `creationRestrictionMode=2` + (protocol-only writes). This is NOT the GroveDB `documentsKeepHistory` + mechanism — `getDocumentHistory` returns empty for DPNS and must not be + used. +- A name in a live contest is **not in the documents tree** — trade + transitions on it fail with a bare `DocumentNotFoundError` (40101), not a + typed "contested" error. The wallet's contested guard exists to produce a + typed error *before* broadcast. +- Purchase balance semantics: the purchase amount is deducted as principal + first; processing fees must fit in the remainder, else + `IdentityInsufficientBalanceError`. + +## 3. Persistence: `DpnsNameStateEntry` (new store, not an `IdentityEntry` change) + +`IdentityEntry` is persisted as an **unversioned positional bincode blob** +(`rs-platform-wallet-storage` `schema/blob.rs`), so extending `DpnsNameInfo` +in place would break decoding of existing rows. Instead, marketplace state is +a new sub-changeset, following the `InvitationEntry` template: + +```rust +pub enum DpnsNameSaleStatus { Owned, Sold { to: Identifier }, Transferred { to: Identifier } } + +pub struct DpnsNameStateEntry { + pub document_id: Identifier, // key; stable across ownership changes + pub wallet_identity_id: Identifier, // which of our identities this row belongs to + pub label: String, + pub normalized_label: String, + pub normalized_parent_domain_name: String, + pub price: Option, // None = not listed + pub status: DpnsNameSaleStatus, + pub created_at_ms: Option, + pub updated_at_ms: Option, + pub transferred_at_ms: Option, + pub last_synced_at_ms: u64, +} + +pub struct DpnsNameStateChangeSet { + pub names: BTreeMap, // LWW per document_id + pub removed: BTreeSet, // tombstones +} +``` + +- Capability bit `DPNS_NAME_STATES` (next free bit), SQLite migration + writer + in `rs-platform-wallet-storage`, FFI persister vtable slot + (`on_persist_dpns_name_states_fn`) mirroring into SwiftData. +- Merge: LWW per `document_id` (every emitter writes fresh rows read from + Platform or from a confirmed transition), tombstone wins over stale upsert + within one changeset generation (same insert-XOR-tombstone discipline as + invitations). +- Sold/Transferred rows are kept (status flips), not deleted — the app shows + "sold" affordances; `removed` exists for hard-delete correctness. +- The legacy `ManagedIdentity.dpns_names: Vec` label list stays + (Swift `PersistentIdentity.dpnsName` selection feeds off it), but its merge + policy changes from append-only-by-label to **last-write-wins wholesale** + (same policy as `contested_dpns_names`, same rationale: sold names must be + able to leave). Every emitter snapshots the full list from managed state, so + LWW converges. This is a merge-policy change only — the bincode layout of + `IdentityEntry` is untouched. + +## 4. Sync + +`DpnsSyncManager` (sibling of `DashPaySyncManager`, same +snapshot/quiesce/log-and-continue skeleton, default 60s cadence; not +auto-started; on-demand FFI entry as well). Per wallet, per identity: + +1. Query domain documents where `records.identity == identity` (existing + indexed query), full documents so `$id`/`$price`/timestamps are read. +2. Upsert `DpnsNameStateEntry` rows; update the legacy label list (add new + labels with `acquired_at` from `$createdAt`/`$transferredAt`, remove + departed labels). +3. For each departed label (present locally, absent from the query), fetch the + domain doc by exact label to learn the new owner → flip the row to + `Sold`/`Transferred` (distinguished by history-contract `purchase` doc when + cheaply determinable, else `Transferred`), emit it in the pass summary. +4. Refresh identity credit balance for identities that sold a name (seller + receives the sale price as credits). +5. Also refreshes `contested_dpns_names` (piggybacks the existing sync). + +Pass summary fires `PlatformEventHandler::on_dpns_sync_completed(&summary)` +so the host can refresh profile/main-username UI when a name (possibly the +main username) left an identity. Rust has no "main username" concept — the +fallback choice stays host-side (Swift `PersistentIdentity.dpnsName`), driven +by the mirrored row updates + the event. + +## 5. Orchestration ops (all on `IdentityWallet`, in `network/dpns_marketplace.rs`) + +Common plumbing: DPNS + history contracts fetched via the existing +`fetch_contract_arc_for_document_op` path (context-provider registration +included) and cached in `OnceLock`s à la `dashpay_contract()`. Signing keys +are auto-selected (`AUTHENTICATION`, ECDSA, security level from the document +type's requirement — the same `allowed_signing_security_levels` rule as +document create); no hardcoded key ids. All broadcasts wrap errors with +`preserve_signer_key_unavailable_or` and the new consensus-error downcasts. + +- `set_dpns_name_price(owner_identity, label, price, signer)`: + authoritative exact-label fetch → ownership check → **contested guard** + (`get_current_dpns_contests` — refuse with `ContestedNameNotTradable`) → + `document_set_price` → upsert row (price from confirmed doc) → return state. +- `delist_dpns_name(owner_identity, label, signer)`: + same guards → `document_transfer` with `recipient == owner` → + **verify the confirmed document carries no `$price`** (honest delist — + error if consensus semantics ever change) → upsert row price=None. +- `purchase_dpns_name(purchaser_identity, label, expected_price, signer)`: + authoritative exact-label fetch → typed pre-checks: `DpnsNameNotFound`, + self-purchase (`InvalidParameter`), `NotForSale`, `PriceChanged{expected, + actual}` → **credit pre-check**: local purchaser balance ≥ expected_price + + `DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS` (0.001 DASH = 100_000_000 credits, + ~2× the observed document-batch fee) else + `InsufficientIdentityCredits{required, available}` → + `document_purchase` **with `expected_price`, never the re-read price** (the + consensus equality check is the backstop; a lost race surfaces as typed + `PriceChanged` via the 40109 downcast) → buyer reconcile (label list + + row + `refresh_identity` for the new balance) → seller reconcile *if the + seller identity is also in this wallet* (label removal, row → Sold, balance + refresh). +- `transfer_dpns_name(owner_identity, label, recipient, signer)`: gift path, + same guards, recipient reconcile if recipient is ours. +- `dpns_name_history(label)`: resolve document id (live doc, or local row for + names that already left) → three `byDocument` queries on the history + contract → merged, `$createdAt`-ordered + `Vec`: + +```rust +pub enum DpnsNameHistoryEventKind { + Registered, // domain doc $createdAt + PriceSet { price: Credits }, // priceUpdate doc + Purchased { price: Credits, seller: Identifier, buyer: Identifier }, + Transferred { from: Identifier, to: Identifier }, // incl. self = delist +} +pub struct DpnsNameHistoryEvent { + pub kind: DpnsNameHistoryEventKind, + pub at_ms: u64, + pub block_height: Option, +} +``` + +- Queries: `search_dpns_names_with_state(prefix, limit, start_after)` and + `dpns_name_state(label)` return `DpnsDomainState` (document id, labels, + owner, records identity, price, timestamps) read straight off the domain + documents — the sale state the SDK's `DpnsUsername` drops. Cursor pagination + uses `DocumentQuery::start` (StartAfter document id) natively, bypassing the + rs-sdk-ffi `start_at` gap. + +## 6. Typed errors + +New `PlatformWalletError` variants (with FFI codes from the free registry +slots, mirrored in `PlatformWalletResultCode` + `PlatformWalletError` (Swift)): + +| Variant | Trigger | FFI detail payload (JSON in `message`) | +|---|---|---| +| `DpnsNameNotFound { name }` | exact-label query empty | — | +| `DocumentNotForSale { document_id }` | pre-check, or 40108 downcast | — | +| `DocumentPriceChanged { document_id, expected, actual }` | pre-check, or 40109 downcast | `{"expected":u64,"actual":u64}` | +| `InsufficientIdentityCredits { identity_id, required, available }` | pre-check, or `IdentityInsufficientBalanceError` downcast | `{"required":u64,"available":u64}` | +| `ContestedNameNotTradable { label, ends_at_ms }` | contested guard | `{"endsAtMs":u64}` | + +Downcast helpers (`as_document_not_for_sale`, `as_incorrect_purchase_price`, +`as_identity_insufficient_balance`) follow the existing +`as_address_invalid_nonce` pattern so consensus rejections arrive typed, not +stringly. The structured-JSON `message` convention for value-carrying codes is +documented at the FFI enum and parsed by swift-sdk into typed Swift cases +(fallback: raw string). + +## 7. Browse-for-sale: protocol limitation (investigated, not buildable here) + +A global "names currently for sale, ordered by price" needs an index on +`$price`. **This cannot ship as a DPNS contract v3.** Verified findings: + +- `$price` is not in rs-dpp's closed `SYSTEM_PROPERTIES` indexable set + (`system_properties/mod.rs`); an index on it fails contract parsing with + `UndefinedIndexPropertyError` — for a system contract that's a node-fatal + load failure, not a soft rejection. +- `serialize_value_for_key` / `get_raw_for_document_type` / + `conditions.rs::meta_field_property_type` all lack `$price` arms — it can be + neither an index key nor a typed where/orderBy field. Unindexed where + clauses are rejected by drive twice over. +- Index definitions are immutable on `DataContractUpdate` for *all* contracts + (`DataContractInvalidIndexDefinitionUpdateError`), and the DPNS owner id is + the unsignable `[0;32]` — only the protocol-upgrade path + (`transition_to_version_N` + `apply_contract`) can change the contract, and + even that creates the new index tree **empty** (no backfill machinery + exists; pre-existing listings would be invisible until re-listed). +- Required upgrade path if this is ever wanted (PV15+): add `$price` to the + indexable set behind a `FeatureVersion` gated on `trade_mode` (the + `$creatorId` precedent), add the three encode/decode arms + query meta-field + typing, new `try_from_schema` generation, DPNS `schema/v3` + + `system_data_contract_versions/v3.rs`, and a `transition_to_version_15` + re-`apply_contract`. Plus a backfill decision. + +**Until then the marketplace is search-driven** (prefix search + per-name sale +state), which is what this layer exposes. Partial aggregate discovery IS +available from the history contract (its user-defined `price` property has +real indices: `byPrice`, averageable) — e.g. recent sales and price history — +and `dpns_name_history` builds on that. A "recently listed" feed could later +be derived from `priceUpdate` documents by `$createdAt` if the app wants it. + +## 8. Verification plan + +- `cargo test -p platform-wallet -p platform-wallet-storage`, clippy, cbindgen + build, `build_ios.sh --target mac` + `swift build` for the Swift layer. +- Testnet end-to-end (documented in §9 once run): register/own name on + identity A → `set_dpns_name_price` → price change → `purchase_dpns_name` + from identity B → `dpns_name_history` shows priceUpdate ×2 + purchase → + `delist_dpns_name` on another listed name confirms transfer-to-self clears + `$price` on the confirmed document and on a fresh query. + +## 9. Testnet verification results + +_Pending — to be filled in by the verification run._ diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c6de98fdae9..eab899798af 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -604,12 +604,13 @@ impl Merge for IdentityChangeSet { // profile via `from_managed`, so LWW converges // correctly within a single wallet. existing.dashpay_profile = entry.dashpay_profile.clone(); - // Append new DPNS names (by label). - for name in &entry.dpns_names { - if !existing.dpns_names.iter().any(|n| n.label == name.label) { - existing.dpns_names.push(name.clone()); - } - } + // DPNS names: last-write-wins wholesale, same policy + // as `contested_dpns_names` below. Every emitter + // snapshots the complete current list via + // `from_managed`, and a sold/transferred name must be + // able to LEAVE the list — the previous append-only- + // by-label merge made departure impossible. + existing.dpns_names = entry.dpns_names.clone(); // The contested-name sync emits the complete canonical // snapshot. Last-write-wins is therefore required so // resolved contests disappear, including when the latest @@ -1035,6 +1036,98 @@ impl Merge for InvitationChangeSet { } } +// --------------------------------------------------------------------------- +// DPNS name states (username marketplace) +// --------------------------------------------------------------------------- + +/// Where a tracked DPNS name currently stands relative to the wallet +/// identity that owned it. +/// +/// `Sold` / `Transferred` rows are retained (not deleted) so the host can +/// surface "your name was sold" affordances; hard removal goes through +/// [`DpnsNameStateChangeSet::removed`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DpnsNameSaleStatus { + /// The wallet identity is the document's `$ownerId`. + Owned, + /// The name left the identity through a purchase; `to` is the buyer. + Sold { to: Identifier }, + /// The name left the identity through a plain transfer (gift / + /// off-market handover); `to` is the recipient. + Transferred { to: Identifier }, +} + +/// One tracked DPNS `domain` document belonging to (or recently departed +/// from) a wallet identity, **with sale state** — the marketplace-facing +/// superset of the label-only `DpnsNameInfo` list. +/// +/// Deliberately a separate store rather than new fields on +/// [`IdentityEntry`]: the identity `entry_blob` is unversioned positional +/// bincode, so growing `DpnsNameInfo` would break decoding of existing +/// rows. Keyed by the domain document id, which is stable across ownership +/// changes. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DpnsNameStateEntry { + /// The DPNS `domain` document id (this record's identity; stable + /// across transfers and purchases). + pub document_id: Identifier, + /// The wallet identity this row is tracked for. For `Owned` rows this + /// equals the document's `$ownerId`; for `Sold`/`Transferred` rows it + /// is the previous owner (ours). + pub wallet_identity_id: Identifier, + /// Display label (e.g. "Alice"). + pub label: String, + /// Homograph-normalized label (e.g. "a11ce"). + pub normalized_label: String, + /// Normalized parent domain (today always "dash"). + pub normalized_parent_domain_name: String, + /// Listed sale price in credits (`$price`). `None` = not for sale. + pub price: Option, + /// Ownership status relative to `wallet_identity_id`. + pub status: DpnsNameSaleStatus, + /// Document `$createdAt` (ms since epoch) when the document carries it. + pub created_at_ms: Option, + /// Document `$updatedAt` (ms) — bumps on price changes. + pub updated_at_ms: Option, + /// Document `$transferredAt` (ms) — set on purchase/transfer. + pub transferred_at_ms: Option, + /// Wall-clock ms of the sync pass / confirmed transition that wrote + /// this row. + pub last_synced_at_ms: u64, +} + +/// DPNS name-state records emitted by the marketplace sync pass and by the +/// set-price / delist / purchase / transfer orchestration ops. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DpnsNameStateChangeSet { + /// Name states keyed by domain document id. Last write wins on merge — + /// every emitter writes a complete row read from Platform or from a + /// confirmed transition, so later rows are strictly fresher. + pub names: BTreeMap, + /// Document ids removed from tracking entirely. + pub removed: BTreeSet, +} + +impl Merge for DpnsNameStateChangeSet { + fn merge(&mut self, other: Self) { + // Last write wins per document id; `names` and `removed` merge + // independently and the sqlite writer applies inserts before + // deletes, so a document id present in both within one merged + // round resolves to "removed" (same insert-XOR-tombstone + // discipline as `InvitationChangeSet` — emit at most one action + // per key per mutation). + self.names.extend(other.names); + self.removed.extend(other.removed); + } + + fn is_empty(&self) -> bool { + self.names.is_empty() && self.removed.is_empty() + } +} + // --------------------------------------------------------------------------- // Token Balances // --------------------------------------------------------------------------- @@ -1440,6 +1533,9 @@ pub struct PlatformWalletChangeSet { pub asset_locks: Option, /// DashPay invitation (DIP-13) records — inviter-side create/reclaim. pub invitations: Option, + /// DPNS name states with sale price (username marketplace) — emitted + /// by the marketplace sync pass and the trade orchestration ops. + pub dpns_name_states: Option, /// Platform token balance / watch changes. pub token_balances: Option, /// DashPay profile overlays keyed by identity ID. Applied AFTER @@ -1548,6 +1644,15 @@ impl From for PlatformWalletChangeSet { } } +impl From for PlatformWalletChangeSet { + fn from(cs: DpnsNameStateChangeSet) -> Self { + Self { + dpns_name_states: Some(cs), + ..Default::default() + } + } +} + impl Merge for PlatformWalletChangeSet { fn merge(&mut self, other: Self) { // `CoreChangeSet` implements `Merge`; delegate via the @@ -1559,6 +1664,7 @@ impl Merge for PlatformWalletChangeSet { self.platform_addresses.merge(other.platform_addresses); self.asset_locks.merge(other.asset_locks); self.invitations.merge(other.invitations); + self.dpns_name_states.merge(other.dpns_name_states); self.token_balances.merge(other.token_balances); // DashPay overlays: LWW per identity_id. if let Some(other_profiles) = other.dashpay_profiles { @@ -1611,6 +1717,7 @@ impl Merge for PlatformWalletChangeSet { && self.platform_addresses.is_empty() && self.asset_locks.is_empty() && self.invitations.is_empty() + && self.dpns_name_states.is_empty() && self.token_balances.is_empty() && self.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()) && self @@ -1689,6 +1796,91 @@ mod tests { assert!(changes.identities[&id].contested_dpns_names.is_empty()); } + fn identity_entry_with_names(id: Identifier, labels: &[&str]) -> IdentityEntry { + let mut entry = identity_entry_with_contested(id, &[]); + entry.dpns_names = labels + .iter() + .map(|label| DpnsNameInfo { + label: (*label).to_owned(), + acquired_at: None, + }) + .collect(); + entry + } + + /// DPNS names merge last-write-wins wholesale (same policy as + /// contested names): a sold/transferred name must be able to LEAVE + /// the list, including via an empty snapshot. Guards the 2026-08 + /// change away from append-only-by-label, which made departure + /// impossible. + #[test] + fn dpns_names_merge_replaces_canonical_snapshot_and_allows_empty() { + let id = Identifier::from([0x52; 32]); + let mut changes = IdentityChangeSet::default(); + changes + .identities + .insert(id, identity_entry_with_names(id, &["sold", "kept"])); + + let mut refreshed = IdentityChangeSet::default(); + refreshed + .identities + .insert(id, identity_entry_with_names(id, &["kept", "bought"])); + changes.merge(refreshed); + let labels: Vec<&str> = changes.identities[&id] + .dpns_names + .iter() + .map(|n| n.label.as_str()) + .collect(); + assert_eq!(labels, ["kept", "bought"]); + + let mut emptied = IdentityChangeSet::default(); + emptied + .identities + .insert(id, identity_entry_with_names(id, &[])); + changes.merge(emptied); + assert!(changes.identities[&id].dpns_names.is_empty()); + } + + /// Marketplace name-state rows merge LWW per document id, with + /// tombstones accumulating independently (insert-XOR-tombstone per + /// mutation round, applied inserts-then-deletes downstream). + #[test] + fn dpns_name_state_merge_is_lww_per_document_with_tombstones() { + let doc = Identifier::from([0x61; 32]); + let other_doc = Identifier::from([0x62; 32]); + let identity = Identifier::from([0x63; 32]); + let buyer = Identifier::from([0x64; 32]); + let entry = |price: Option, status: DpnsNameSaleStatus| DpnsNameStateEntry { + document_id: doc, + wallet_identity_id: identity, + label: "Alice".into(), + normalized_label: "a11ce".into(), + normalized_parent_domain_name: "dash".into(), + price, + status, + created_at_ms: Some(1), + updated_at_ms: None, + transferred_at_ms: None, + last_synced_at_ms: 2, + }; + + let mut cs = DpnsNameStateChangeSet::default(); + assert!(cs.is_empty()); + cs.names + .insert(doc, entry(Some(5_000), DpnsNameSaleStatus::Owned)); + + let mut sold = DpnsNameStateChangeSet::default(); + sold.names + .insert(doc, entry(None, DpnsNameSaleStatus::Sold { to: buyer })); + sold.removed.insert(other_doc); + cs.merge(sold); + + assert_eq!(cs.names[&doc].price, None); + assert_eq!(cs.names[&doc].status, DpnsNameSaleStatus::Sold { to: buyer }); + assert!(cs.removed.contains(&other_doc)); + assert!(!cs.is_empty()); + } + /// The deferred contact-crypto queue rides the changeset as add/clear /// deltas: a pending enqueue OR a pending clear must mark the changeset /// non-empty (so the persist round isn't skipped and the queue survives a diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 913ea54d51b..fbd6cc50ec0 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -29,9 +29,10 @@ pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, - HighestUsedIndexes, IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, - IdentityKeyEntry, IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, - InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, + DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, HighestUsedIndexes, + IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, + IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, + KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index b477ad27dd9..61c4afa50f8 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -46,6 +46,8 @@ impl PersistenceCapabilities { pub const DEFERRED_CONTACT_CRYPTO: Self = Self::PENDING_CONTACT_CRYPTO; /// A persisted core wallet snapshot can be loaded after process restart. pub const WALLET_RESTORE: Self = Self(1 << 7); + /// DPNS name-state (username marketplace) rows can be persisted. + pub const DPNS_NAME_STATES: Self = Self(1 << 8); /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -113,6 +115,10 @@ impl PersistenceCapabilities { "pending_contact_crypto", ), (PersistenceCapabilities::WALLET_RESTORE, "wallet_restore"), + ( + PersistenceCapabilities::DPNS_NAME_STATES, + "dpns_name_states", + ), ]; KNOWN @@ -140,6 +146,7 @@ mod tests { assert_eq!(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE.bits(), 0x20); assert_eq!(PersistenceCapabilities::PENDING_CONTACT_CRYPTO.bits(), 0x40); assert_eq!(PersistenceCapabilities::WALLET_RESTORE.bits(), 0x80); + assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); } #[test] diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 9d6ce8a0a4e..f018bc37b87 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -255,6 +255,61 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), + /// No DPNS `domain` document exists for the requested name (exact + /// normalized-label lookup came back empty). Distinct from + /// [`Self::InvalidParameter`]: the input was well-formed, the name + /// just isn't registered (or is hidden inside an unresolved contest — + /// see [`Self::ContestedNameNotTradable`] for the pre-checked case). + #[error("DPNS name not found: {name:?}")] + DpnsNameNotFound { name: String }, + + /// The DPNS domain document carries no `$price` — it is not listed + /// for sale. Raised by the wallet's pre-flight check and by the + /// consensus downcast of `DocumentNotForSaleError` (DPP code 40108). + #[error("document {document_id} is not for sale")] + DocumentNotForSale { document_id: Identifier }, + + /// The listed price no longer equals the price the user confirmed. + /// Raised pre-flight (fresh read ≠ confirmed price) and by the + /// consensus downcast of `DocumentIncorrectPurchasePriceError` (DPP + /// code 40109) when the listing changed between the pre-flight read + /// and broadcast — the purchase did NOT execute in either case. + #[error( + "document {document_id} price changed: purchase was confirmed at \ + {expected} credits but the listing is now {actual} credits" + )] + DocumentPriceChanged { + document_id: Identifier, + expected: Credits, + actual: Credits, + }, + + /// The identity's credit balance cannot cover the operation + /// (principal + fee margin for pre-flight checks; Platform's own + /// arithmetic for the consensus downcast of + /// `IdentityInsufficientBalanceError`). + #[error( + "identity {identity_id} has insufficient credits: {required} required, \ + {available} available" + )] + InsufficientIdentityCredits { + identity_id: Identifier, + required: Credits, + available: Credits, + }, + + /// The name is inside an active contested-name vote, so its domain + /// document is not yet in the documents tree and cannot be listed, + /// transferred, or purchased. Without this guard the network returns + /// a bare `DocumentNotFoundError` (40101), which reads as "no such + /// name" — this typed error says what is actually going on. + /// `ends_at_ms == 0` means the vote's end time was unavailable. + #[error( + "DPNS name {label:?} is in an active contested-name vote \ + (ends at {ends_at_ms} ms) and cannot be traded until the contest resolves" + )] + ContestedNameNotTradable { label: String, ends_at_ms: u64 }, + /// Platform rejected an address-funds transition because a spent address's /// provided nonce did not equal its expected next value (DPP consensus code /// 40603, `AddressInvalidNonceError`) — an optimistic `fetched + 1` nonce @@ -641,6 +696,80 @@ pub fn promote_address_nonce_error_or_sdk(error: dash_sdk::Error) -> PlatformWal promote_address_nonce_error(&error).unwrap_or(PlatformWalletError::Sdk(error)) } +/// Extract the consensus verdict from the `dash_sdk::Error` shapes that can +/// carry one — `StateTransitionBroadcastError` (wait-stream), +/// `Protocol(ConsensusError)` (CheckTx), and the dapi-client's +/// exhausted-retry envelope it recurses into. Shared by the typed-promotion +/// matchers below; the same coverage caveat as +/// [`as_asset_lock_proof_cl_height_too_low`] applies (re-audit when +/// `dash_sdk::Error` gains consensus-carrying variants). +fn consensus_error_of(error: &dash_sdk::Error) -> Option<&dpp::consensus::ConsensusError> { + match error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => consensus_error_of(inner), + _ => None, + } +} + +/// Promote a document-trade consensus rejection to its typed +/// [`PlatformWalletError`] so callers get structured data instead of a +/// stringified verdict: +/// +/// - `DocumentNotForSaleError` (40108) → [`PlatformWalletError::DocumentNotForSale`] +/// - `DocumentIncorrectPurchasePriceError` (40109) → +/// [`PlatformWalletError::DocumentPriceChanged`] (carries both prices — +/// the race-lost purchase case; the transition did NOT execute) +/// - `IdentityInsufficientBalanceError` → +/// [`PlatformWalletError::InsufficientIdentityCredits`] +/// +/// Returns `None` for anything else, leaving the caller's fallback mapping +/// in charge. +pub fn promote_document_trade_error(error: &dash_sdk::Error) -> Option { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + + match consensus_error_of(error)? { + ConsensusError::StateError(StateError::DocumentNotForSaleError(e)) => { + Some(PlatformWalletError::DocumentNotForSale { + document_id: *e.document_id(), + }) + } + ConsensusError::StateError(StateError::DocumentIncorrectPurchasePriceError(e)) => { + Some(PlatformWalletError::DocumentPriceChanged { + document_id: *e.document_id(), + expected: e.trying_to_purchase_at_price(), + actual: e.actual_price(), + }) + } + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(e)) => { + Some(PlatformWalletError::InsufficientIdentityCredits { + identity_id: *e.identity_id(), + required: e.required_balance(), + available: e.balance(), + }) + } + _ => None, + } +} + +/// Map a document-trade transition's SDK error to a [`PlatformWalletError`]: +/// typed trade rejections first ([`promote_document_trade_error`]), then the +/// structured signer-key-unavailable preservation, then the caller's `wrap` +/// fallback. Owned-error `.map_err(...)?` analogue for the set-price / +/// purchase / transfer call sites. +pub fn promote_document_trade_error_or( + error: dash_sdk::Error, + wrap: impl FnOnce(dash_sdk::Error) -> PlatformWalletError, +) -> PlatformWalletError { + if let Some(promoted) = promote_document_trade_error(&error) { + return promoted; + } + preserve_signer_key_unavailable_or(error, wrap) +} + /// The reserved machine prefix that a typed `SigningKeyUnavailable` signer /// completion stamps at the **start** of its `ProtocolError::Generic` payload. /// Also stamped at position 0 of `MnemonicResolverCoreSigner::NotFound`'s diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index 9ac256e8730..c329c5a32f5 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -16,6 +16,7 @@ use arc_swap::ArcSwap; pub use dash_spv::EventHandler; pub use key_wallet_manager::WalletEvent; +use crate::manager::dpns_sync::DpnsSyncPassSummary; use crate::manager::platform_address_sync::PlatformAddressSyncSummary; #[cfg(feature = "shielded")] use crate::manager::shielded_sync::ShieldedSyncPassSummary; @@ -33,6 +34,17 @@ pub trait PlatformEventHandler: EventHandler { /// [`PlatformAddressSyncManager`]: crate::manager::platform_address_sync::PlatformAddressSyncManager fn on_platform_address_sync_completed(&self, _summary: &PlatformAddressSyncSummary) {} + /// Fired after each [`DpnsSyncManager`] marketplace pass completes, + /// including passes that produced no delta. Hosts refresh + /// marketplace UI from the mirrored rows and — when the summary + /// reports a name departing an identity — re-run their + /// main-username selection / profile display for that identity. + /// + /// Default impl is a no-op so existing handlers don't have to care. + /// + /// [`DpnsSyncManager`]: crate::manager::dpns_sync::DpnsSyncManager + fn on_dpns_marketplace_sync_completed(&self, _summary: &DpnsSyncPassSummary) {} + /// Fired after each [`ShieldedSyncManager`] pass completes, /// including passes that produced no updates or skipped every /// wallet because none had a bound shielded sub-wallet yet. @@ -130,6 +142,17 @@ impl PlatformEventManager { } } + /// Dispatch a DPNS marketplace sync completion to every handler. + /// + /// Not on the SPV hot path — called once per DPNS sync pass + /// (~60s by default). + pub fn on_dpns_marketplace_sync_completed(&self, summary: &DpnsSyncPassSummary) { + let handlers = self.handlers.load(); + for h in handlers.iter() { + h.on_dpns_marketplace_sync_completed(summary); + } + } + /// Dispatch a shielded sync completion to every handler. /// /// Not on the SPV hot path — called once per shielded sync pass diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index e0daf2bd638..4905ba3b377 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -14,6 +14,7 @@ use key_wallet::WalletCoreBalance; use crate::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; use crate::manager::dashpay_sync::DashPaySyncManager; +use crate::manager::dpns_sync::DpnsSyncManager; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] @@ -340,6 +341,17 @@ impl PlatformWalletManager

{ Arc::clone(&self.dashpay_sync_manager) } + /// Access the recurring DPNS username-marketplace sync coordinator. + pub fn dpns_sync(&self) -> &DpnsSyncManager { + &self.dpns_sync_manager + } + + /// Clone the `Arc` so callers (e.g. FFI) can invoke + /// [`DpnsSyncManager::start`] which takes `&Arc`. + pub fn dpns_sync_arc(&self) -> Arc { + Arc::clone(&self.dpns_sync_manager) + } + /// Access the shielded sync coordinator. #[cfg(feature = "shielded")] pub fn shielded_sync(&self) -> &ShieldedSyncManager { diff --git a/packages/rs-platform-wallet/src/manager/dpns_sync.rs b/packages/rs-platform-wallet/src/manager/dpns_sync.rs new file mode 100644 index 00000000000..ef376031921 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/dpns_sync.rs @@ -0,0 +1,323 @@ +//! Periodic DPNS username-marketplace sync coordinator. +//! +//! Folds the marketplace refresh — owned-name sale state (`$price`), +//! newly acquired names, and names that LEFT an identity (sold / +//! transferred away) — into the recurring background loop, alongside the +//! platform-address, identity-token, DashPay, and shielded coordinators. +//! Before this, DPNS state only refreshed when the host explicitly +//! called an FFI sync entry point. +//! +//! **Wallet-driven, not registry-driven — by design.** A sibling of +//! [`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager): it +//! holds the same `wallets` map, snapshots the wallet `Arc`s under a +//! read guard each sweep, and refreshes **every** wallet. It is a +//! separate coordinator (not a seventh DashPay step) because the DashPay +//! pass is contact/profile-scoped and runs at a 15s cadence, while +//! marketplace state changes are rare — this loop defaults to 60s. +//! +//! The per-wallet refresh is one `IdentityWallet` domain operation, +//! [`sync_dpns_marketplace`](crate::wallet::identity::IdentityWallet::sync_dpns_marketplace) +//! (which also has a standalone on-demand FFI caller); the coordinator +//! owns only the sweep, the log-and-continue policy, and the completion +//! event dispatch. +//! +//! Each pass: +//! 1. Snapshots the wallet map (short read lock, no await while held). +//! 2. Runs `sync_dpns_marketplace()` per wallet (log-and-continue). +//! 3. Stores the pass timestamp and dispatches +//! [`PlatformEventManager::on_dpns_marketplace_sync_completed`]. +//! +//! `sync_now` is re-entrant-safe (an in-flight pass makes it return an +//! empty summary immediately) and shutdown drains an in-flight pass via +//! [`quiesce`](DpnsSyncManager::quiesce), exactly like the sibling +//! coordinators. +//! +//! Not auto-started. Call [`DpnsSyncManager::start`] once the wallets +//! are registered and the SDK is connected. + +use std::collections::BTreeMap; +use std::num::NonZeroUsize; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tokio::sync::RwLock; + +use dash_async::{ThreadRegistry, WorkerConfig}; + +use crate::events::PlatformEventManager; +use crate::manager::{ + coordinator_worker_config, drain_pass, QuiesceGate, QuiesceGuard, SyncSlotGuard, WalletWorker, + COORDINATOR_DRAIN_BUDGET, +}; +use crate::wallet::identity::network::DpnsMarketplaceSyncSummary; +use crate::wallet::platform_wallet::WalletId; +use crate::wallet::PlatformWallet; + +/// Default cadence for the DPNS marketplace sync loop. +/// +/// Marketplace state (listings, sales) changes far less often than +/// DashPay contact state, and each pass costs one indexed document query +/// per identity — 60s keeps sale/departure detection timely without +/// multiplying DAPI traffic. Tunable at runtime via +/// [`DpnsSyncManager::set_interval`]. +pub const DEFAULT_SYNC_INTERVAL_SECS: u64 = 60; + +/// Stack size for the DPNS sync loop's OS thread. +/// +/// The pass verifies GroveDB document-query proofs (domain-document and +/// history-contract fetches), whose recursive descent overflows the +/// platform default thread stack — same rationale and size as the +/// DashPay coordinator and the FFI worker convention (`runtime.rs`). +const DPNS_SYNC_STACK_BYTES: usize = 8 * 1024 * 1024; + +/// Outcome of syncing a single wallet's marketplace state in a pass. +#[derive(Debug)] +pub enum WalletDpnsSyncOutcome { + /// `sync_dpns_marketplace()` completed; carries its per-wallet delta. + Ok(DpnsMarketplaceSyncSummary), + /// `sync_dpns_marketplace()` returned an error message (logged, + /// non-fatal to the rest of the pass). + Err(String), +} + +impl WalletDpnsSyncOutcome { + pub fn is_ok(&self) -> bool { + matches!(self, WalletDpnsSyncOutcome::Ok(_)) + } +} + +/// Summary of one full DPNS marketplace sync pass across every +/// registered wallet. +#[derive(Debug, Default)] +pub struct DpnsSyncPassSummary { + /// Per-wallet outcomes keyed by `WalletId`. + pub wallet_results: BTreeMap, + /// Unix seconds at which the pass completed. `0` means "no pass ran" + /// (a concurrent pass was already in flight and we skipped). + pub sync_unix_seconds: u64, +} + +impl DpnsSyncPassSummary { + pub fn is_empty(&self) -> bool { + self.wallet_results.is_empty() + } + + pub fn success_count(&self) -> usize { + self.wallet_results.values().filter(|o| o.is_ok()).count() + } + + pub fn error_count(&self) -> usize { + self.wallet_results.len() - self.success_count() + } + + /// Whether any wallet reported a marketplace delta (names added, + /// departed, or re-priced) this pass. + pub fn has_delta(&self) -> bool { + self.wallet_results.values().any(|o| match o { + WalletDpnsSyncOutcome::Ok(s) => !s.is_empty_delta(), + WalletDpnsSyncOutcome::Err(_) => false, + }) + } +} + +/// Periodic DPNS username-marketplace sync coordinator. See the module +/// docs for the design; the lifecycle (start / stop / quiesce semantics, +/// registry-owned thread, deep stack) mirrors +/// [`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager) +/// verbatim. +pub struct DpnsSyncManager { + wallets: Arc>>>, + registry: Arc>, + /// Dispatches `on_dpns_marketplace_sync_completed` after each pass. + events: Arc, + interval_secs: AtomicU64, + is_syncing: AtomicBool, + /// Gates new passes while a [`quiesce`](Self::quiesce) drains an + /// in-flight one — same barrier contract as the sibling coordinators. + quiescing: QuiesceGate, + /// Unix seconds of the last completed pass. `0` = never. + last_sync_unix: AtomicU64, +} + +impl DpnsSyncManager { + pub fn new( + wallets: Arc>>>, + registry: Arc>, + events: Arc, + ) -> Self { + Self { + wallets, + registry, + events, + interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), + is_syncing: AtomicBool::new(false), + quiescing: QuiesceGate::default(), + last_sync_unix: AtomicU64::new(0), + } + } + + /// Set the polling interval. Clamped to a minimum of 1s. The running + /// loop picks this up on its next sleep. + pub fn set_interval(&self, interval: Duration) { + let secs = interval.as_secs().max(1); + self.interval_secs.store(secs, Ordering::Release); + } + + /// Current polling interval. + pub fn interval(&self) -> Duration { + Duration::from_secs(self.interval_secs.load(Ordering::Acquire)) + } + + /// Whether the background loop is currently running. + pub fn is_running(&self) -> bool { + self.registry.is_running(WalletWorker::DpnsSync) + } + + /// Whether a sync pass is in flight right now. + pub fn is_syncing(&self) -> bool { + self.is_syncing.load(Ordering::Acquire) + } + + /// Unix seconds of the last completed pass, or `None` if no pass has + /// ever completed. + pub fn last_sync_unix_seconds(&self) -> Option { + match self.last_sync_unix.load(Ordering::Acquire) { + 0 => None, + n => Some(n), + } + } + + /// Start the background sync loop. Idempotent — calling while + /// already running is a no-op. Runs on a dedicated registry-owned OS + /// thread with a deep stack, driving the (`!Send`) SDK futures via + /// `Handle::block_on` — same mechanism and rationale as + /// `DashPaySyncManager::start`. The first pass runs immediately. + pub fn start(self: Arc) { + let handle = tokio::runtime::Handle::current(); + let registry = Arc::clone(&self.registry); + let this = self; + let cfg = WorkerConfig { + stack_size: NonZeroUsize::new(DPNS_SYNC_STACK_BYTES), + ..coordinator_worker_config() + }; + registry.start_thread(WalletWorker::DpnsSync, cfg, move |cancel| { + handle.block_on(async move { + loop { + if cancel.is_cancelled() { + break; + } + + this.sync_now().await; + + let interval = this.interval(); + tokio::select! { + _ = tokio::time::sleep(interval) => {} + _ = cancel.cancelled() => break, + } + } + }); + }); + } + + /// Stop the background sync loop. Cancel-only — a pass already + /// inside `sync_now` keeps running to completion; use + /// [`quiesce`](Self::quiesce) for a real drain barrier. + pub fn stop(&self) { + self.registry.cancel(WalletWorker::DpnsSync); + } + + /// Cancel the loop and wait for any in-flight pass to fully drain — + /// same contract as `DashPaySyncManager::quiesce`. + #[must_use = "a false return means the pass did NOT drain; the caller must fail closed"] + pub async fn quiesce(&self) -> bool { + self.quiesce_within(COORDINATOR_DRAIN_BUDGET).await + } + + /// [`quiesce`](Self::quiesce) with an explicit drain budget. On + /// timeout the admission gate is left closed and the caller must + /// fail closed. + pub(crate) async fn quiesce_within(&self, budget: Duration) -> bool { + self.quiesce_held_within(budget).await.is_some() + } + + /// Drain variant that keeps sync admission shut until the returned + /// guard drops. + #[must_use = "None means the pass did NOT drain; the caller must fail closed"] + pub(crate) async fn quiesce_held_within(&self, budget: Duration) -> Option> { + drain_pass(&self.quiescing, &self.is_syncing, || self.stop(), budget).await + } + + /// Drain variant that **seals** admission permanently — used by + /// manager shutdown so a mid-flight host-thread `sync_now` cannot + /// start a fresh pass (and fire persister/event callbacks) after the + /// host freed its context. + pub(crate) async fn quiesce_sealed_within(&self, budget: Duration) -> bool { + let guard = self.quiesce_held_within(budget).await; + let drained = guard.is_some(); + // Seal before the guard drops so its Drop cannot reopen. + self.quiescing.seal(); + drop(guard); + drained + } + + /// Run one marketplace sync pass across every registered wallet. + /// + /// If a pass is already in flight, returns an empty summary and + /// skips — the caller can inspect [`Self::is_syncing`] to + /// distinguish. Per-wallet errors are logged and recorded in the + /// summary but never abort the sweep. Dispatches the completion + /// event before returning. + pub async fn sync_now(&self) -> DpnsSyncPassSummary { + if self + .is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return DpnsSyncPassSummary::default(); + } + // Clears `is_syncing` on every exit path — including panic + // unwind — so a failed pass can never wedge `quiesce()`'s drain. + let _slot = SyncSlotGuard(&self.is_syncing); + + // A `quiesce()` may have raised the gate between our CAS and + // here; bail so the drain gets a true barrier. + if self.quiescing.is_closed() { + return DpnsSyncPassSummary::default(); + } + + let snapshot: Vec<(WalletId, Arc)> = { + let wallets = self.wallets.read().await; + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + }; + + let mut summary = DpnsSyncPassSummary::default(); + for (wallet_id, wallet) in snapshot { + let outcome = match wallet.identity().sync_dpns_marketplace().await { + Ok(wallet_summary) => WalletDpnsSyncOutcome::Ok(wallet_summary), + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DPNS marketplace sync failed for wallet; continuing with the rest" + ); + WalletDpnsSyncOutcome::Err(e.to_string()) + } + }; + summary.wallet_results.insert(wallet_id, outcome); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + summary.sync_unix_seconds = now; + self.last_sync_unix.store(now, Ordering::Release); + + self.events.on_dpns_marketplace_sync_completed(&summary); + + summary + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 65f410f3395..4a4d8a9d9ce 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -99,6 +99,7 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, + dpns_name_states: std::collections::BTreeMap::new(), }; // Canonical id recomputed from the wallet's own key material. diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index e3257045a5c..fc4e47b15fe 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -2,6 +2,7 @@ pub mod accessors; pub mod dashpay_sync; +pub mod dpns_sync; pub mod identity_sync; mod load; pub mod platform_address_sync; @@ -22,6 +23,7 @@ use key_wallet_manager::WalletManager; use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; use crate::events::{PlatformEventHandler, PlatformEventManager}; use crate::manager::dashpay_sync::DashPaySyncManager; +use crate::manager::dpns_sync::DpnsSyncManager; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] @@ -49,6 +51,8 @@ pub enum WalletWorker { IdentitySync, /// DashPay (contact requests + profiles) sync coordinator. DashPaySync, + /// DPNS username-marketplace sync coordinator. + DpnsSync, /// Shielded (Orchard) note sync coordinator. ShieldedSync, /// SPV runtime — the network event source feeding every persister- @@ -347,6 +351,12 @@ pub struct PlatformWalletManager { /// auto-started — call `start` after wallets are registered. See /// [`DashPaySyncManager`]. pub(super) dashpay_sync_manager: Arc, + /// Periodic DPNS username-marketplace sync coordinator. Drives + /// `sync_dpns_marketplace()` (owned-name sale state + departure + /// detection) on **every** registered wallet each sweep; shares the + /// same `wallets` map as [`DashPaySyncManager`]. Not auto-started — + /// call `start` after wallets are registered. See [`DpnsSyncManager`]. + pub(super) dpns_sync_manager: Arc, /// Tracks asynchronous payment hooks so manager shutdown can close /// admission and drain every task before host callback contexts are freed. pub(super) dashpay_payment_handler: Arc, @@ -492,6 +502,13 @@ impl PlatformWalletManager

{ Arc::clone(&wallets), Arc::clone(®istry), )); + // DPNS marketplace sync also sweeps the `wallets` map; it takes + // the event manager to dispatch its pass-completion event. + let dpns_sync = Arc::new(DpnsSyncManager::new( + Arc::clone(&wallets), + Arc::clone(®istry), + Arc::clone(&event_manager), + )); #[cfg(feature = "shielded")] let shielded_coordinator: Arc< RwLock>>, @@ -511,6 +528,7 @@ impl PlatformWalletManager

{ platform_address_sync_manager: platform_address_sync, identity_sync_manager: identity_sync, dashpay_sync_manager: dashpay_sync, + dpns_sync_manager: dpns_sync, dashpay_payment_handler, #[cfg(feature = "shielded")] shielded_sync_manager: shielded_sync, @@ -855,24 +873,28 @@ impl PlatformWalletManager

{ // run a full pass — and fire persister / completion callbacks — // after `destroy` returned and the host freed those contexts. #[cfg(feature = "shielded")] - let (pa_drained, id_drained, dp_drained, sh_drained) = tokio::join!( + let (pa_drained, id_drained, dp_drained, dpns_drained, sh_drained) = tokio::join!( self.platform_address_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.identity_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.dashpay_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), + self.dpns_sync_manager + .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.shielded_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), ); #[cfg(not(feature = "shielded"))] - let (pa_drained, id_drained, dp_drained) = tokio::join!( + let (pa_drained, id_drained, dp_drained, dpns_drained) = tokio::join!( self.platform_address_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.identity_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.dashpay_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), + self.dpns_sync_manager + .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), ); // Hard-join the coordinator loop threads now that every in-flight @@ -889,6 +911,7 @@ impl PlatformWalletManager

{ (WalletWorker::PlatformAddressSync, pa_drained), (WalletWorker::IdentitySync, id_drained), (WalletWorker::DashPaySync, dp_drained), + (WalletWorker::DpnsSync, dpns_drained), #[cfg(feature = "shielded")] (WalletWorker::ShieldedSync, sh_drained), ]; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 8a99e65a644..c9eafee286b 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -363,6 +363,7 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + dpns_name_states: std::collections::BTreeMap::new(), }; wallet.downgrade_to_external_signable(); diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 19f64f0d534..274207c720d 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -254,6 +254,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -323,6 +324,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -424,6 +426,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -499,6 +502,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -670,6 +674,7 @@ pub(crate) async fn mnemonic_wallet_manager( generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 993ccff4134..4390740640c 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -105,6 +105,7 @@ impl PlatformWalletInfo { // is future). Drop explicitly so future readers don't expect a // replay hook. invitations: _, + dpns_name_states, // Registration-round metadata / per-account specs / // per-pool snapshots are persistence-only — the // canonical in-memory wallet state is built up at @@ -161,6 +162,18 @@ impl PlatformWalletInfo { } } + // 2a. DPNS name states (username marketplace): upserts land + // first, then tombstones, into the in-memory working set — + // same LWW-then-remove discipline as the rest of this + // function. + if let Some(dpns_cs) = dpns_name_states { + let crate::changeset::DpnsNameStateChangeSet { names, removed } = dpns_cs; + self.dpns_name_states.extend(names); + for document_id in &removed { + self.dpns_name_states.remove(document_id); + } + } + // 2b. Identity keys. Runs after the scalar identity pass so // the owning ManagedIdentity is guaranteed to exist before // we layer keys into it. Upserts land first, then removals, @@ -413,6 +426,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 5a89934bf8e..1f67e96d1ec 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -772,6 +772,7 @@ mod tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 5df388e7bae..ac821c2e3b7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3365,6 +3365,7 @@ mod sweep_tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index 49be83f313a..53648ec4fc4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -116,7 +116,7 @@ where /// flow correct for *any* document type — e.g. DPNS `preorder` requires /// `HIGH`, so both `CRITICAL` and `HIGH` keys qualify, but `MEDIUM` does /// not. -fn allowed_signing_security_levels(requirement: SecurityLevel) -> Vec { +pub(super) fn allowed_signing_security_levels(requirement: SecurityLevel) -> Vec { if requirement == SecurityLevel::MASTER { return vec![SecurityLevel::MASTER]; } @@ -652,11 +652,12 @@ impl IdentityWallet { .document_transfer(builder, &signing_key, &SignerRef(signer)) .await .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData` + // Typed trade rejections (not-for-sale / price-changed / + // insufficient credits) and the structured key-unavailable + // signer failure survive; only genuine operation failures + // get stringified into `InvalidIdentityData` // (dashpay/platform#4183 review). - crate::error::preserve_signer_key_unavailable_or(e, |e| { + crate::error::promote_document_trade_error_or(e, |e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to transfer document: {e}" )) @@ -715,11 +716,11 @@ impl IdentityWallet { .document_set_price(builder, &signing_key, &SignerRef(signer)) .await .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData` + // Typed trade rejections and the structured key-unavailable + // signer failure survive; only genuine operation failures + // get stringified into `InvalidIdentityData` // (dashpay/platform#4183 review). - crate::error::preserve_signer_key_unavailable_or(e, |e| { + crate::error::promote_document_trade_error_or(e, |e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to set document price: {e}" )) @@ -783,11 +784,13 @@ impl IdentityWallet { .document_purchase(builder, &signing_key, &SignerRef(signer)) .await .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData` - // (dashpay/platform#4183 review). - crate::error::preserve_signer_key_unavailable_or(e, |e| { + // Typed trade rejections — crucially the price-changed race + // (40109), where the consensus equality check is the backstop + // behind the wallet's pre-flight — and the structured + // key-unavailable signer failure survive; only genuine + // operation failures get stringified into + // `InvalidIdentityData` (dashpay/platform#4183 review). + crate::error::promote_document_trade_error_or(e, |e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to purchase document: {e}" )) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs new file mode 100644 index 00000000000..1db474b9ff2 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -0,0 +1,1440 @@ +//! DPNS username marketplace: wallet-level search / sell / delist / +//! purchase / transfer orchestration, per-name trade history, and the +//! local name-state bookkeeping behind them. +//! +//! Design record: `rs-platform-wallet/docs/DPNS_MARKETPLACE.md`. +//! +//! The generic document-trade transitions live in `document.rs` +//! (`set_document_price_with_signer` / `purchase_document_with_signer` / +//! `transfer_document_with_signer`); this module composes them with the +//! DPNS specifics the app layer should not own: +//! +//! - resolving a name to its `domain` document (with `$price` and the +//! document id, which the SDK's `DpnsUsername` drops), +//! - automatic signing-key selection (AUTHENTICATION / ECDSA at the +//! document type's required security level — no hardcoded key ids), +//! - typed pre-flight checks (not-found / contested / not-for-sale / +//! price-changed / insufficient credits), +//! - local persistence of sale state through the changeset pipeline +//! ([`DpnsNameStateEntry`] rows + the legacy `dpns_names` label list), +//! - the trade-history timeline from the Document History system +//! contract. +//! +//! Consensus facts this module relies on (verified against rs-drive; see +//! the design doc §2): purchase and transfer both REMOVE `$price` +//! (transfer-to-self is therefore the delist primitive); purchase +//! requires the transition price to equal the listed price; +//! `records.identity` is rewritten to the new owner by the protocol on +//! purchase/transfer; a name inside an active contested-name vote is not +//! in the documents tree at all. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use dpp::document::property_names::PRICE; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; + +use dash_sdk::drive::query::{OrderClause, SelectProjection, WhereClause, WhereOperator}; +use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, is_contested_username}; +use dash_sdk::platform::{DocumentQuery, FetchMany}; +use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + +use crate::changeset::{DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry}; +use crate::error::PlatformWalletError; +use crate::wallet::identity::types::key_storage::DpnsNameInfo; + +use super::document::allowed_signing_security_levels; +use super::*; + +/// DPNS document type carrying registered names. +const DPNS_DOCUMENT_TYPE: &str = "domain"; +/// The only DPNS parent domain in production. +const DPNS_PARENT_DOMAIN: &str = "dash"; + +/// Document History system contract document types (see +/// `packages/document-history-contract/schema/v1/...`). All three carry +/// `dataContractId` / `documentId` and a `byDocument` +/// (dataContractId, documentId, $createdAt) index. +const HISTORY_TYPE_TRANSFER: &str = "transfer"; +const HISTORY_TYPE_PURCHASE: &str = "purchase"; +const HISTORY_TYPE_PRICE_UPDATE: &str = "priceUpdate"; + +/// Conservative fee reserve (credits) required ON TOP of the purchase +/// price before a purchase is attempted: Platform deducts the purchase +/// amount as principal first and the processing fee must fit in the +/// remainder (`validate_fees_of_event`). The observed document-batch +/// transition fee is well under 0.0005 DASH; 0.001 DASH (1 duff = 1000 +/// credits) keeps a ~2x margin. The actual fee is metered at execution +/// from the buyer identity's credits; this constant only gates the +/// pre-flight, it is never broadcast. +pub const DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS: Credits = 100_000_000; + +/// Default page size for marketplace search queries. +const DEFAULT_SEARCH_LIMIT: u32 = 25; +/// Page size for the per-identity domain-document sync query. +const SYNC_QUERY_LIMIT: u32 = 100; +/// Page size for per-name history queries (per event type). +const HISTORY_QUERY_LIMIT: u32 = 100; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// A DPNS `domain` document read straight off Platform, keeping the +/// marketplace-relevant system fields the SDK's `DpnsUsername` drops: +/// the document id (the handle every trade transition needs) and +/// `$price` (the sale state). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsDomainState { + /// The domain document id — stable across transfers and purchases. + pub document_id: Identifier, + /// Display label (e.g. "Alice"). + pub label: String, + /// Homograph-normalized label (e.g. "a11ce"). + pub normalized_label: String, + /// Normalized parent domain ("dash"). + pub normalized_parent_domain_name: String, + /// The document's `$ownerId` — the identity that owns (and may sell) + /// the name. + pub owner_id: Identifier, + /// `records.identity` — the identity the name points at. The + /// protocol rewrites this to the new owner on purchase/transfer. + pub records_identity_id: Option, + /// Listed sale price in credits (`$price`). `None` = not for sale. + pub price: Option, + /// Document `$createdAt` in ms, when carried. + pub created_at_ms: Option, + /// Document `$updatedAt` in ms — bumps on price changes. + pub updated_at_ms: Option, + /// Document `$transferredAt` in ms — set on purchase/transfer. + pub transferred_at_ms: Option, +} + +impl DpnsDomainState { + /// Read the marketplace-relevant fields off a DPNS `domain` document. + /// + /// Errors (rather than fabricating defaults) when required schema + /// fields are missing or mistyped — a malformed `$price` must not + /// silently read as "not for sale". + fn from_document(doc: &Document) -> Result { + let properties = doc.properties(); + let text = |key: &str| -> Result { + properties + .get(key) + .and_then(|v| v.as_text()) + .map(str::to_string) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS domain document {} is missing required text field {key:?}", + doc.id() + )) + }) + }; + let price = properties + .get_optional_integer::(PRICE) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS domain document {} carries a malformed $price: {e}", + doc.id() + )) + })?; + // `records.identity` — same manual map walk as the SDK's + // `document_to_dpns_username` (the value is an identifier). + let records_identity_id = if let Some(Value::Map(records)) = properties.get("records") { + records + .iter() + .find(|(k, _)| k.as_text() == Some("identity")) + .and_then(|(_, v)| v.to_identifier().ok()) + } else { + None + }; + Ok(Self { + document_id: doc.id(), + label: text("label")?, + normalized_label: text("normalizedLabel")?, + normalized_parent_domain_name: text("normalizedParentDomainName")?, + owner_id: doc.owner_id(), + records_identity_id, + price, + created_at_ms: doc.created_at(), + updated_at_ms: doc.updated_at(), + transferred_at_ms: doc.transferred_at(), + }) + } + + /// Build the local persisted row for this state, tracked for + /// `wallet_identity_id` with `status`. + fn to_entry( + &self, + wallet_identity_id: Identifier, + status: DpnsNameSaleStatus, + now_ms: u64, + ) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: self.document_id, + wallet_identity_id, + label: self.label.clone(), + normalized_label: self.normalized_label.clone(), + normalized_parent_domain_name: self.normalized_parent_domain_name.clone(), + price: self.price, + status, + created_at_ms: self.created_at_ms, + updated_at_ms: self.updated_at_ms, + transferred_at_ms: self.transferred_at_ms, + last_synced_at_ms: now_ms, + } + } +} + +/// One event in a name's trade timeline, assembled from the Document +/// History system contract plus the domain document's own `$createdAt`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsNameHistoryEvent { + pub kind: DpnsNameHistoryEventKind, + /// Block time of the event in ms (`$createdAt` of the history + /// document; registration uses the domain document's `$createdAt`). + pub at_ms: u64, + /// Block height of the event, when carried. + pub block_height: Option, +} + +/// What happened at a point in a name's trade timeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsNameHistoryEventKind { + /// The domain document was registered (its `$createdAt`). + Registered, + /// The owner listed / re-priced the name (`priceUpdate` history doc). + PriceSet { price: Credits }, + /// The name was purchased: `seller` received `price` credits from + /// `buyer`, who became the owner (`purchase` history doc). + Purchased { + price: Credits, + seller: Identifier, + buyer: Identifier, + }, + /// The name was transferred without payment — a gift/handover, or a + /// transfer-to-self delist when `from == to` (`transfer` history doc). + Transferred { from: Identifier, to: Identifier }, +} + +/// One name that left a wallet identity, observed by +/// [`IdentityWallet::sync_dpns_marketplace`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepartedDpnsName { + pub identity_id: Identifier, + pub label: String, + pub document_id: Option, + /// `Some(Sold { to })` when the departure is attributable to a + /// purchase, `Some(Transferred { to })` when the new owner is known + /// but no purchase matches, and `None` when the domain document could + /// not be resolved at all (deleted name / fetch failure) — unknown is + /// reported as unknown, never as a fabricated counterparty. + pub status: Option, +} + +/// A listed-price change observed between two sync passes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsPriceChange { + pub document_id: Identifier, + pub label: String, + pub previous: Option, + pub current: Option, +} + +/// Summary of one [`IdentityWallet::sync_dpns_marketplace`] pass. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DpnsMarketplaceSyncSummary { + /// Name-state rows written this pass (owned names refreshed). + pub names_tracked: u32, + /// Labels newly observed on a wallet identity: `(identity, label)`. + pub names_added: Vec<(Identifier, String)>, + /// Names that left a wallet identity since the local snapshot. + pub names_departed: Vec, + /// Listed-price changes since the local snapshot. + pub prices_changed: Vec, + /// Wall-clock ms at which the pass completed. + pub sync_unix_ms: u64, +} + +impl DpnsMarketplaceSyncSummary { + pub fn is_empty_delta(&self) -> bool { + self.names_added.is_empty() + && self.names_departed.is_empty() + && self.prices_changed.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Contract caches +// --------------------------------------------------------------------------- + +/// Process-wide cached DPNS data contract (bundled system contract, same +/// caching rationale as [`super::dashpay_contract`]). Used for *query +/// building*; the trade transitions keep fetching the on-chain contract +/// through `fetch_contract_arc_for_document_op` (which also registers it +/// for post-broadcast proof verification). +pub(crate) fn dpns_contract() -> Result, PlatformWalletError> { + static CONTRACT: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(contract) = CONTRACT.get() { + return Ok(Arc::clone(contract)); + } + let contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::DPNS, + dpp::version::PlatformVersion::latest(), + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to load DPNS contract: {e}")) + })?; + let arc = Arc::new(contract); + let _ = CONTRACT.set(Arc::clone(&arc)); + Ok(CONTRACT.get().map(Arc::clone).unwrap_or(arc)) +} + +/// Process-wide cached Document History system contract — the event log +/// DPNS v2's `keeps*History` flags write `transfer` / `purchase` / +/// `priceUpdate` documents into. NOT the GroveDB `documentsKeepHistory` +/// mechanism (`getDocumentHistory` returns empty for DPNS). +pub(crate) fn document_history_contract() -> Result, PlatformWalletError> { + static CONTRACT: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(contract) = CONTRACT.get() { + return Ok(Arc::clone(contract)); + } + let contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::DocumentHistory, + dpp::version::PlatformVersion::latest(), + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to load Document History contract: {e}" + )) + })?; + let arc = Arc::new(contract); + let _ = CONTRACT.set(Arc::clone(&arc)); + Ok(CONTRACT.get().map(Arc::clone).unwrap_or(arc)) +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +/// Best-effort wall-clock ms (same shape as the `acquired_at` stamps in +/// `dpns.rs`). `0` only if the system clock is before the epoch. +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Strip an optional ".dash" suffix so callers can pass either "alice" +/// or "alice.dash". +fn dpns_label(name: &str) -> &str { + name.strip_suffix(".dash").unwrap_or(name) +} + +impl IdentityWallet { + // ----------------------------------------------------------------- + // Queries (network reads, sale state included) + // ----------------------------------------------------------------- + + /// Search DPNS names by prefix, returning full domain state (document + /// id, owner, `$price`, timestamps) ordered by normalized label. + /// + /// An empty prefix is a valid alphabetical browse (equality on the + /// parent domain + orderBy label). `start_after` is the cursor: pass + /// the last row's `document_id` to fetch the next page. There is NO + /// server-side price filter or ordering — `$price` is not indexable + /// (design doc §7); the marketplace is search-driven. + pub async fn search_dpns_names_with_state( + &self, + prefix: &str, + limit: Option, + start_after: Option, + ) -> Result, PlatformWalletError> { + let contract = dpns_contract()?; + let normalized_prefix = convert_to_homograph_safe_chars(dpns_label(prefix)); + let mut where_clauses = vec![WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(DPNS_PARENT_DOMAIN.to_string()), + }]; + if !normalized_prefix.is_empty() { + where_clauses.push(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text(normalized_prefix), + }); + } + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: DPNS_DOCUMENT_TYPE.to_string(), + where_clauses, + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }], + limit: limit.unwrap_or(DEFAULT_SEARCH_LIMIT), + offset: None, + start: start_after.map(|id| Start::StartAfter(id.to_vec())), + }; + self.fetch_domain_states(query).await + } + + /// Fetch the single DPNS domain document for `name` ("alice" or + /// "alice.dash"), or `None` when no such document is in the tree. + pub async fn dpns_name_state( + &self, + name: &str, + ) -> Result, PlatformWalletError> { + let contract = dpns_contract()?; + let normalized = convert_to_homograph_safe_chars(dpns_label(name)); + if normalized.is_empty() { + return Err(PlatformWalletError::InvalidParameter( + "DPNS name must not be empty".to_string(), + )); + } + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: DPNS_DOCUMENT_TYPE.to_string(), + where_clauses: vec![ + WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(DPNS_PARENT_DOMAIN.to_string()), + }, + WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(normalized), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + }; + Ok(self.fetch_domain_states(query).await?.into_iter().next()) + } + + /// Fetch the domain documents associated with `identity_id` via the + /// `records.identity` index (the only identity-keyed index; the + /// protocol rewrites `records.identity` to the new owner on + /// purchase/transfer, so this stays authoritative across sales). + pub async fn dpns_domain_states_for_identity( + &self, + identity_id: &Identifier, + limit: Option, + ) -> Result, PlatformWalletError> { + let contract = dpns_contract()?; + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: DPNS_DOCUMENT_TYPE.to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: limit.unwrap_or(SYNC_QUERY_LIMIT), + offset: None, + start: None, + }; + self.fetch_domain_states(query).await + } + + /// The locally persisted marketplace rows (owned names with sale + /// state, plus retained `Sold`/`Transferred` rows), optionally + /// filtered to one wallet identity. Reads the in-memory working set — + /// no network. + pub async fn local_dpns_name_states( + &self, + identity_id: Option<&Identifier>, + ) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + Ok(info + .dpns_name_states + .values() + .filter(|entry| identity_id.is_none_or(|id| entry.wallet_identity_id == *id)) + .cloned() + .collect()) + } + + /// Run `query` and convert the returned documents, preserving server + /// order (the result map is an `IndexMap`). + async fn fetch_domain_states( + &self, + query: DocumentQuery, + ) -> Result, PlatformWalletError> { + let documents = Document::fetch_many(&self.sdk, query).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch DPNS domain documents: {e}" + )) + })?; + documents + .into_iter() + .filter_map(|(_, doc)| doc) + .map(|doc| DpnsDomainState::from_document(&doc)) + .collect() + } + + /// Resolve `name` to its domain state or fail typed: a name hidden + /// inside an active contested-name vote is NOT in the documents tree + /// (the network would answer any trade with a bare + /// `DocumentNotFoundError`), so the miss is classified before it is + /// reported — [`PlatformWalletError::ContestedNameNotTradable`] when + /// an active contest holds the label, + /// [`PlatformWalletError::DpnsNameNotFound`] otherwise. + async fn fetch_dpns_domain_state_required( + &self, + name: &str, + ) -> Result { + if let Some(state) = self.dpns_name_state(name).await? { + return Ok(state); + } + let label = dpns_label(name); + if is_contested_username(label) { + let normalized = convert_to_homograph_safe_chars(label); + let contests = self + .sdk + .get_current_dpns_contests(None, None, None) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to check contested-name votes for {label:?}: {e}" + )) + })?; + if let Some(end_time_ms) = contests.get(&normalized) { + return Err(PlatformWalletError::ContestedNameNotTradable { + label: label.to_string(), + ends_at_ms: *end_time_ms, + }); + } + } + Err(PlatformWalletError::DpnsNameNotFound { + name: name.to_string(), + }) + } + + // ----------------------------------------------------------------- + // Signing-key selection + // ----------------------------------------------------------------- + + /// Auto-select the signing key for a DPNS `domain` state transition + /// on `identity_id`: the identity's first AUTHENTICATION-purpose + /// ECDSA_SECP256K1 key whose security level satisfies the document + /// type's requirement (the same consensus rule + /// [`allowed_signing_security_levels`] encodes). Replaces the app + /// layer's hardcoded "key id 1". + async fn select_dpns_signing_key( + &self, + identity_id: &Identifier, + ) -> Result { + let contract = dpns_contract()?; + let required_level = contract + .document_type_for_name(DPNS_DOCUMENT_TYPE) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS contract has no {DPNS_DOCUMENT_TYPE:?} document type: {e}" + )) + })? + .security_level_requirement(); + let allowed_levels = allowed_signing_security_levels(required_level); + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let identity = info + .identity_manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + allowed_levels.iter().copied().collect(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "No ECDSA authentication key at a security level satisfying \ + {required_level} found on identity {identity_id} \ + (required to sign a DPNS domain state transition)" + )) + }) + } + + // ----------------------------------------------------------------- + // Local bookkeeping + // ----------------------------------------------------------------- + + /// Upsert marketplace rows (and optional removals) into the in-memory + /// working set and emit the changeset so the host mirror persists it. + async fn record_dpns_name_states( + &self, + entries: Vec, + removed: Vec, + ) { + if entries.is_empty() && removed.is_empty() { + return; + } + let mut cs = DpnsNameStateChangeSet::default(); + for entry in entries { + cs.names.insert(entry.document_id, entry); + } + cs.removed.extend(removed); + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + info.dpns_name_states.extend(cs.names.clone()); + for document_id in &cs.removed { + info.dpns_name_states.remove(document_id); + } + // Same best-effort discipline as `add_dpns_name`: the in-memory + // mutation stands for this session; a failed store is logged and + // the next sync pass re-emits the same rows (self-healing). + if let Err(e) = self.persister.store(cs.into()) { + tracing::error!("Failed to persist DPNS name states: {e}"); + } + } + + /// Add `label` to `identity_id`'s legacy label list if absent + /// (persisting the identity snapshot). No-op when already present. + async fn add_dpns_label_if_missing( + &self, + identity_id: &Identifier, + label: &str, + acquired_at: Option, + ) { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { + return; + }; + if managed.dpns_names.iter().any(|n| n.label == label) { + return; + } + managed.add_dpns_name( + DpnsNameInfo { + label: label.to_string(), + acquired_at, + }, + &self.persister, + ); + } + + /// Remove `label` from `identity_id`'s legacy label list (persisting + /// the identity snapshot). No-op when absent or the identity isn't + /// in this wallet. + async fn remove_dpns_label(&self, identity_id: &Identifier, label: &str) { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { + return; + }; + managed.remove_dpns_name(label, &self.persister); + } + + /// Whether `identity_id` is one of this wallet's identities. + async fn is_wallet_identity(&self, identity_id: &Identifier) -> bool { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| info.identity_manager.identity(identity_id).is_some()) + .unwrap_or(false) + } + + /// Best-effort identity refresh after a trade moved credits or + /// ownership: failures are logged, never propagated — the trade + /// already executed on Platform and must be reported as such. + async fn refresh_identity_after_trade(&self, identity_id: &Identifier, context: &str) { + if let Err(e) = self.refresh_identity(identity_id).await { + tracing::warn!( + identity = %identity_id, + "post-{context} identity refresh failed (will self-heal on next sync): {e}" + ); + } + } + + // ----------------------------------------------------------------- + // Sell / delist / transfer / purchase orchestration + // ----------------------------------------------------------------- + + /// List (or re-price) `name` for sale at `price` credits. + /// + /// Pre-flight: the name must resolve to a domain document owned by + /// `owner_identity_id` (typed contested/not-found errors otherwise). + /// The signing key is auto-selected on the owner. On success the + /// local sale state is persisted from the confirmed document and the + /// updated state returned. + pub async fn set_dpns_name_price( + &self, + owner_identity_id: &Identifier, + name: &str, + price: Credits, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let state = self.fetch_dpns_domain_state_required(name).await?; + if state.owner_id != *owner_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} is owned by {}, not by {owner_identity_id}", + state.owner_id + ))); + } + let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let contract_id = dpns_contract()?.id(); + let confirmed = self + .set_document_price_with_signer( + owner_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + price, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*owner_identity_id, DpnsNameSaleStatus::Owned, now_ms())], + vec![], + ) + .await; + Ok(confirmed_state) + } + + /// Delist `name` — a transfer to the owner's own identity, which + /// consensus strips `$price` from while leaving ownership unchanged + /// (DPNS has no dedicated remove-price transition and + /// `documentsMutable=false` rules out a replace). + /// + /// The confirmed document is verified to actually carry no `$price` + /// and the same owner; if consensus semantics ever change, this + /// fails loudly instead of persisting a delist that didn't happen. + pub async fn delist_dpns_name( + &self, + owner_identity_id: &Identifier, + name: &str, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let state = self.fetch_dpns_domain_state_required(name).await?; + if state.owner_id != *owner_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} is owned by {}, not by {owner_identity_id}", + state.owner_id + ))); + } + if state.price.is_none() { + return Err(PlatformWalletError::DocumentNotForSale { + document_id: state.document_id, + }); + } + let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let contract_id = dpns_contract()?.id(); + let confirmed = self + .transfer_document_with_signer( + owner_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + owner_identity_id, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + if confirmed_state.price.is_some() || confirmed_state.owner_id != *owner_identity_id { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "delist of {name:?} broadcast a self-transfer but the confirmed document \ + still carries price={:?} owner={} — transfer-to-self no longer clears \ + $price; do not trust the local delist state", + confirmed_state.price, confirmed_state.owner_id + ))); + } + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*owner_identity_id, DpnsNameSaleStatus::Owned, now_ms())], + vec![], + ) + .await; + Ok(confirmed_state) + } + + /// Transfer `name` to `recipient_id` (gift / off-market handover). + /// Consensus strips any `$price` on transfer, so this also delists. + /// + /// Both sides are reconciled locally when they belong to this wallet: + /// the sender loses the label (row → `Transferred`), a wallet-owned + /// recipient gains it (row → `Owned`). + pub async fn transfer_dpns_name( + &self, + owner_identity_id: &Identifier, + name: &str, + recipient_id: &Identifier, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + if recipient_id == owner_identity_id { + return Err(PlatformWalletError::InvalidParameter( + "transfer recipient is the current owner — use delist_dpns_name for a \ + transfer-to-self delist" + .to_string(), + )); + } + let state = self.fetch_dpns_domain_state_required(name).await?; + if state.owner_id != *owner_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} is owned by {}, not by {owner_identity_id}", + state.owner_id + ))); + } + let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let contract_id = dpns_contract()?.id(); + let confirmed = self + .transfer_document_with_signer( + owner_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + recipient_id, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + let now = now_ms(); + self.remove_dpns_label(owner_identity_id, &confirmed_state.label) + .await; + if self.is_wallet_identity(recipient_id).await { + // Both sides ours: the single per-document row tracks the new + // owner; the departure is visible through the label removal. + self.add_dpns_label_if_missing( + recipient_id, + &confirmed_state.label, + confirmed_state.transferred_at_ms.or(Some(now)), + ) + .await; + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*recipient_id, DpnsNameSaleStatus::Owned, now)], + vec![], + ) + .await; + } else { + self.record_dpns_name_states( + vec![confirmed_state.to_entry( + *owner_identity_id, + DpnsNameSaleStatus::Transferred { to: *recipient_id }, + now, + )], + vec![], + ) + .await; + } + Ok(confirmed_state) + } + + /// Purchase `name` at exactly `expected_price` credits (the price the + /// user confirmed) for `purchaser_identity_id`. + /// + /// Pre-flight, all typed: name resolution (contested-aware), a + /// self-purchase guard, [`PlatformWalletError::DocumentNotForSale`], + /// [`PlatformWalletError::DocumentPriceChanged`] when the listing no + /// longer matches `expected_price`, and + /// [`PlatformWalletError::InsufficientIdentityCredits`] when the + /// buyer's local balance can't cover + /// `expected_price + `[`DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS`]. + /// + /// The broadcast transition carries `expected_price` — NEVER the + /// re-read price — so a listing change between pre-flight and + /// broadcast is rejected by consensus (code 40109) and surfaces as + /// the same typed `DocumentPriceChanged`. On success both sides are + /// reconciled locally (buyer gains the label + row; a wallet-owned + /// seller loses the label, row → `Sold`) and both identities' + /// balances are refreshed best-effort. + pub async fn purchase_dpns_name( + &self, + purchaser_identity_id: &Identifier, + name: &str, + expected_price: Credits, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let state = self.fetch_dpns_domain_state_required(name).await?; + if state.owner_id == *purchaser_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "identity {purchaser_identity_id} already owns DPNS name {name:?}" + ))); + } + let listed_price = state + .price + .ok_or(PlatformWalletError::DocumentNotForSale { + document_id: state.document_id, + })?; + if listed_price != expected_price { + return Err(PlatformWalletError::DocumentPriceChanged { + document_id: state.document_id, + expected: expected_price, + actual: listed_price, + }); + } + // Credit pre-flight against the local balance snapshot: Platform + // deducts the price as principal first, then the processing fee + // must fit in the remainder. The consensus-side + // `IdentityInsufficientBalanceError` (typed through + // `promote_document_trade_error`) is the backstop for a stale + // local balance. + let available = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.identity_manager + .managed_identity(purchaser_identity_id) + .map(|m| m.balance()) + .ok_or(PlatformWalletError::IdentityNotFound( + *purchaser_identity_id, + ))? + }; + let required = + expected_price.saturating_add(DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS); + if available < required { + return Err(PlatformWalletError::InsufficientIdentityCredits { + identity_id: *purchaser_identity_id, + required, + available, + }); + } + let signing_key = self.select_dpns_signing_key(purchaser_identity_id).await?; + let contract_id = dpns_contract()?.id(); + let confirmed = self + .purchase_document_with_signer( + purchaser_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + expected_price, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + let now = now_ms(); + let seller_id = state.owner_id; + + // Buyer side: label + row + balance. + self.add_dpns_label_if_missing( + purchaser_identity_id, + &confirmed_state.label, + confirmed_state.transferred_at_ms.or(Some(now)), + ) + .await; + self.record_dpns_name_states( + vec![confirmed_state.to_entry( + *purchaser_identity_id, + DpnsNameSaleStatus::Owned, + now, + )], + vec![], + ) + .await; + self.refresh_identity_after_trade(purchaser_identity_id, "purchase (buyer)") + .await; + + // Seller side, when the seller is also one of this wallet's + // identities: the sold name leaves the label list (the host's + // main-username selection falls back to the remaining labels off + // the mirrored identity row) and the seller's balance — which + // just received the sale price — is refreshed. + if self.is_wallet_identity(&seller_id).await { + self.remove_dpns_label(&seller_id, &confirmed_state.label) + .await; + self.refresh_identity_after_trade(&seller_id, "purchase (seller)") + .await; + } + Ok(confirmed_state) + } + + // ----------------------------------------------------------------- + // History + // ----------------------------------------------------------------- + + /// The trade timeline of `name`: registration, price changes, + /// purchases (with price + counterparties), and transfers — read + /// from the Document History system contract's `priceUpdate` / + /// `purchase` / `transfer` documents (`byDocument` index), merged + /// and ordered by block time ascending. + /// + /// Works for names that already left the wallet: when the live + /// domain document can't be resolved, the document id is taken from + /// the local marketplace rows. + pub async fn dpns_name_history( + &self, + name: &str, + ) -> Result, PlatformWalletError> { + // Resolve the domain document id (live first, local rows for + // departed names) and the registration timestamp when known. + let live = self.dpns_name_state(name).await?; + let (document_id, registered_at_ms) = match &live { + Some(state) => (state.document_id, state.created_at_ms), + None => { + let normalized = convert_to_homograph_safe_chars(dpns_label(name)); + let local = self + .local_dpns_name_states(None) + .await? + .into_iter() + .find(|entry| entry.normalized_label == normalized); + match local { + Some(entry) => (entry.document_id, entry.created_at_ms), + None => { + // Reuse the contested-aware classification for the + // typed error. If the name appeared between the two + // reads (registration race), just use it. + match self.fetch_dpns_domain_state_required(name).await { + Ok(state) => (state.document_id, state.created_at_ms), + Err(e) => return Err(e), + } + } + } + } + }; + self.dpns_document_history(&document_id, registered_at_ms) + .await + } + + /// History timeline for a known domain `document_id`. See + /// [`Self::dpns_name_history`]. + pub async fn dpns_document_history( + &self, + document_id: &Identifier, + registered_at_ms: Option, + ) -> Result, PlatformWalletError> { + let dpns_contract_id = dpns_contract()?.id(); + let mut events: Vec = Vec::new(); + if let Some(at_ms) = registered_at_ms { + events.push(DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Registered, + at_ms, + block_height: None, + }); + } + for doc_type in [ + HISTORY_TYPE_PRICE_UPDATE, + HISTORY_TYPE_PURCHASE, + HISTORY_TYPE_TRANSFER, + ] { + let docs = self + .fetch_history_documents(&dpns_contract_id, document_id, doc_type) + .await?; + for doc in docs { + events.push(history_event_from_document(doc_type, &doc)?); + } + } + events.sort_by_key(|e| e.at_ms); + Ok(events) + } + + /// Fetch one history document type's rows for a source document via + /// the `byDocument` (dataContractId, documentId, $createdAt) index. + async fn fetch_history_documents( + &self, + source_contract_id: &Identifier, + source_document_id: &Identifier, + history_doc_type: &str, + ) -> Result, PlatformWalletError> { + let contract = document_history_contract()?; + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: history_doc_type.to_string(), + where_clauses: vec![ + WhereClause { + field: "dataContractId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(source_contract_id.to_buffer()), + }, + WhereClause { + field: "documentId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(source_document_id.to_buffer()), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }], + limit: HISTORY_QUERY_LIMIT, + offset: None, + start: None, + }; + let documents = Document::fetch_many(&self.sdk, query).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch {history_doc_type} history documents: {e}" + )) + })?; + Ok(documents.into_iter().filter_map(|(_, doc)| doc).collect()) + } + + // ----------------------------------------------------------------- + // Sync + // ----------------------------------------------------------------- + + /// One marketplace sync pass over every identity in this wallet: + /// refreshes owned-name rows (price/sale state), adds newly observed + /// names to the legacy label list, detects names that LEFT an + /// identity (sold or transferred away — classified through the + /// history contract), removes their labels, and refreshes the + /// balances of identities that sold a name. + /// + /// All network reads happen before the wallet-manager write lock is + /// taken; per-identity failures are logged and skipped, never + /// aborting the pass. + pub async fn sync_dpns_marketplace( + &self, + ) -> Result { + // Snapshot identity ids, their label lists, and the current rows. + let (identity_ids, labels_by_identity, previous_rows) = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let ids = info.identity_manager.identity_ids(); + let labels: BTreeMap> = ids + .iter() + .filter_map(|id| { + info.identity_manager + .managed_identity(id) + .map(|m| (*id, m.dpns_names.clone())) + }) + .collect(); + (ids, labels, info.dpns_name_states.clone()) + }; + + let mut summary = DpnsMarketplaceSyncSummary::default(); + let mut rows_to_write: Vec = Vec::new(); + let mut sellers_to_refresh: Vec = Vec::new(); + let now = now_ms(); + + for identity_id in identity_ids { + let states = match self + .dpns_domain_states_for_identity(&identity_id, None) + .await + { + Ok(states) => states, + Err(e) => { + tracing::warn!( + identity = %identity_id, + "DPNS marketplace sync: domain-state fetch failed, skipping identity: {e}" + ); + continue; + } + }; + // `records.identity` follows ownership on-chain, but filter on + // `$ownerId` anyway so a protocol edge (or pre-rewrite record) + // can't count someone else's document as ours. + let owned: Vec<&DpnsDomainState> = states + .iter() + .filter(|s| s.owner_id == identity_id) + .collect(); + let owned_normalized: Vec = + owned.iter().map(|s| s.normalized_label.clone()).collect(); + + let previous_labels = labels_by_identity + .get(&identity_id) + .cloned() + .unwrap_or_default(); + + // Owned rows: upsert, tracking price changes vs the previous row. + for state in &owned { + if let Some(prev) = previous_rows.get(&state.document_id) { + if prev.wallet_identity_id == identity_id && prev.price != state.price { + summary.prices_changed.push(DpnsPriceChange { + document_id: state.document_id, + label: state.label.clone(), + previous: prev.price, + current: state.price, + }); + } + } + rows_to_write.push(state.to_entry(identity_id, DpnsNameSaleStatus::Owned, now)); + summary.names_tracked += 1; + } + + // Newly observed labels → legacy list additions. + for state in &owned { + let known = previous_labels.iter().any(|n| { + convert_to_homograph_safe_chars(&n.label) == state.normalized_label + }); + if !known { + self.add_dpns_label_if_missing( + &identity_id, + &state.label, + state + .transferred_at_ms + .or(state.created_at_ms) + .or(Some(now)), + ) + .await; + summary.names_added.push((identity_id, state.label.clone())); + } + } + + // Departed labels: previously listed on the identity, no longer + // among its owned documents. + for prev_name in &previous_labels { + let normalized = convert_to_homograph_safe_chars(&prev_name.label); + if owned_normalized.contains(&normalized) { + continue; + } + let departed = self + .resolve_departed_name(&identity_id, &prev_name.label, &previous_rows, now) + .await; + self.remove_dpns_label(&identity_id, &prev_name.label).await; + if let Some(entry) = departed.1 { + rows_to_write.push(entry); + } + if matches!(departed.0.status, Some(DpnsNameSaleStatus::Sold { .. })) { + sellers_to_refresh.push(identity_id); + } + summary.names_departed.push(departed.0); + } + } + + self.record_dpns_name_states(rows_to_write, vec![]).await; + sellers_to_refresh.sort(); + sellers_to_refresh.dedup(); + for seller in sellers_to_refresh { + self.refresh_identity_after_trade(&seller, "marketplace sync (sold name)") + .await; + } + summary.sync_unix_ms = now_ms(); + Ok(summary) + } + + /// Work out what happened to a name that left `identity_id`: fetch + /// the domain document by label to learn the new owner, then + /// classify the departure through the history contract. + /// + /// Returns the summary record plus the updated row (when the + /// document could be resolved). + async fn resolve_departed_name( + &self, + identity_id: &Identifier, + label: &str, + previous_rows: &BTreeMap, + now: u64, + ) -> (DepartedDpnsName, Option) { + let state = match self.dpns_name_state(label).await { + Ok(Some(state)) => state, + Ok(None) | Err(_) => { + // Document gone (deleted name) or unreadable: report the + // departure without a new owner; keep any old row as-is. + let document_id = previous_rows + .values() + .find(|e| { + e.wallet_identity_id == *identity_id + && e.normalized_label == convert_to_homograph_safe_chars(label) + }) + .map(|e| e.document_id); + return ( + DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id, + status: None, + }, + None, + ); + } + }; + let status = self + .classify_departure(&state.document_id, &state.owner_id, state.transferred_at_ms) + .await; + let entry = state.to_entry(*identity_id, status, now); + ( + DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: Some(state.document_id), + status: Some(status), + }, + Some(entry), + ) + } + + /// Sold vs transferred: the protocol stamps the history `purchase` + /// document and the domain's `$transferredAt` from the same block + /// time, so a purchase event whose buyer is the new owner at exactly + /// the domain's transfer timestamp means the departure was a sale. + /// Anything else — including an unavailable history query — reports + /// as `Transferred` (the enum's documented fallback), never a + /// fabricated `Sold`. + async fn classify_departure( + &self, + document_id: &Identifier, + new_owner: &Identifier, + domain_transferred_at_ms: Option, + ) -> DpnsNameSaleStatus { + let dpns_contract_id = match dpns_contract() { + Ok(c) => c.id(), + Err(_) => { + return DpnsNameSaleStatus::Transferred { to: *new_owner }; + } + }; + let purchases = match self + .fetch_history_documents(&dpns_contract_id, document_id, HISTORY_TYPE_PURCHASE) + .await + { + Ok(docs) => docs, + Err(e) => { + tracing::warn!( + document = %document_id, + "purchase-history lookup failed; reporting departure as transfer: {e}" + ); + return DpnsNameSaleStatus::Transferred { to: *new_owner }; + } + }; + let sold = purchases.iter().any(|doc| { + doc.owner_id() == *new_owner + && domain_transferred_at_ms.is_some() + && doc.created_at() == domain_transferred_at_ms + }); + if sold { + DpnsNameSaleStatus::Sold { to: *new_owner } + } else { + DpnsNameSaleStatus::Transferred { to: *new_owner } + } + } +} + +// --------------------------------------------------------------------------- +// History document decoding +// --------------------------------------------------------------------------- + +/// Decode one Document History contract document into a timeline event. +/// Errors on missing/mistyped required fields rather than fabricating +/// values (`priceUpdate`/`purchase` must carry `price`, `transfer` must +/// carry `toIdentityId`, all must carry `$createdAt`). +fn history_event_from_document( + doc_type: &str, + doc: &Document, +) -> Result { + let properties = doc.properties(); + let at_ms = doc.created_at().ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "history document {} ({doc_type}) is missing $createdAt", + doc.id() + )) + })?; + let price = || -> Result { + properties + .get_optional_integer::("price") + .ok() + .flatten() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "history document {} ({doc_type}) is missing its price field", + doc.id() + )) + }) + }; + let identifier = |key: &str| -> Result { + properties + .get(key) + .and_then(|v| v.to_identifier().ok()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "history document {} ({doc_type}) is missing identifier field {key:?}", + doc.id() + )) + }) + }; + let kind = match doc_type { + HISTORY_TYPE_PRICE_UPDATE => DpnsNameHistoryEventKind::PriceSet { price: price()? }, + HISTORY_TYPE_PURCHASE => DpnsNameHistoryEventKind::Purchased { + price: price()?, + seller: identifier("sellerId")?, + buyer: doc.owner_id(), + }, + HISTORY_TYPE_TRANSFER => DpnsNameHistoryEventKind::Transferred { + from: doc.owner_id(), + to: identifier("toIdentityId")?, + }, + other => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "unknown history document type {other:?}" + ))) + } + }; + Ok(DpnsNameHistoryEvent { + kind, + at_ms, + block_height: doc.created_at_block_height(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dpns_label_strips_only_the_dash_suffix() { + assert_eq!(dpns_label("alice"), "alice"); + assert_eq!(dpns_label("alice.dash"), "alice"); + assert_eq!(dpns_label("alice.dash.dash"), "alice.dash"); + } + + #[test] + fn fee_reserve_is_one_millidash() { + // 0.001 DASH = 100_000 duffs? No: 1 DASH = 100_000_000 duffs, so + // 0.001 DASH = 100_000 duffs = 100_000_000 credits (1 duff = + // 1000 credits). Pin the constant against unit drift. + assert_eq!(DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, 100_000 * 1_000); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index bbcc27c09e4..3ac7e8cacfe 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -24,6 +24,7 @@ mod contract; mod discovery; mod document; mod dpns; +mod dpns_marketplace; mod identity_handle; mod loading; mod register_from_addresses; @@ -70,6 +71,10 @@ pub use contact_requests::{ pub use dashpay_view::DashPayView; pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; +pub use dpns_marketplace::{ + DepartedDpnsName, DpnsDomainState, DpnsMarketplaceSyncSummary, DpnsNameHistoryEvent, + DpnsNameHistoryEventKind, DpnsPriceChange, DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, +}; pub use identity_handle::{ derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 933f3aad5c6..8e4b98bc635 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -240,6 +240,37 @@ impl ManagedIdentity { } } + /// Replace the DPNS-name list wholesale. + /// + /// Use this when a sync round (or a confirmed sale/transfer) has the + /// canonical set of names owned by this identity. `IdentityChangeSet::merge` + /// and replay both treat this field as a complete last-write-wins + /// snapshot, so names that left the identity (sold / transferred + /// away) are removed, including by an empty snapshot — the same + /// policy as [`Self::set_contested_dpns_names`]. + pub fn set_dpns_names(&mut self, names: Vec, persister: &WalletPersister) { + self.dpns_names = names; + let cs = self.snapshot_changeset(); + if let Err(e) = persister.store(cs.into()) { + tracing::error!("Failed to persist changeset: {}", e); + } + } + + /// Remove one DPNS name by label (the sold / transferred-away case). + /// + /// No-op (no changeset emitted) when the label isn't present. + pub fn remove_dpns_name(&mut self, label: &str, persister: &WalletPersister) { + let before = self.dpns_names.len(); + self.dpns_names.retain(|n| n.label != label); + if self.dpns_names.len() == before { + return; + } + let cs = self.snapshot_changeset(); + if let Err(e) = persister.store(cs.into()) { + tracing::error!("Failed to persist changeset: {}", e); + } + } + /// Append a contested DPNS label this identity is contending for. /// /// Dedup is enforced — the same label isn't added twice. When a diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 398356cf218..0080effaa3c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -38,8 +38,9 @@ impl IdentityManager { /// as applying it once. If the identity already exists in either /// bucket, the scalar fields are updated in place; balance/revision /// are gated on `entry.revision >= existing.identity.revision()` - /// matching the merge policy on `IdentityChangeSet`. Contested DPNS - /// labels are a complete canonical snapshot and are assigned wholesale. + /// matching the merge policy on `IdentityChangeSet`. DPNS labels and + /// contested DPNS labels are complete canonical snapshots and are + /// assigned wholesale. pub(crate) fn apply_identity_entry(&mut self, entry: IdentityEntry) { use dpp::identity::accessors::IdentitySettersV0; @@ -55,11 +56,11 @@ impl IdentityManager { existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; *existing.dashpay_profile_mut() = entry.dashpay_profile; - for name in entry.dpns_names { - if !existing.dpns_names.iter().any(|n| n.label == name.label) { - existing.dpns_names.push(name); - } - } + // DPNS names: wholesale assign, matching the changeset's + // last-write-wins merge — entries carry the complete list + // (snapshotted via `from_managed`), and a sold/transferred + // name must be able to leave it. + existing.dpns_names = entry.dpns_names; existing.contested_dpns_names = entry.contested_dpns_names; existing .dashpay_payments_mut() diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index bf28a640c2d..bc48c6d1234 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -64,6 +64,12 @@ pub struct PlatformWalletInfo { pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// DPNS name states with sale price (username marketplace), keyed by + /// domain document id. Session-lifetime working set for the + /// marketplace sync/orchestration ops; the durable copy is the + /// host-side persister mirror fed by + /// [`DpnsNameStateChangeSet`](crate::changeset::DpnsNameStateChangeSet). + pub dpns_name_states: BTreeMap, } /// A platform wallet that combines core UTXO functionality with identity management. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 62b1cef00ea..b4a2f7d05b0 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,6 +40,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + dpns_name_states: std::collections::BTreeMap::new(), } } @@ -52,6 +53,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + dpns_name_states: std::collections::BTreeMap::new(), } } From 4adfca78f336fc461c14799a0f8f4ec7d681e672 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 14:50:21 +0700 Subject: [PATCH 02/13] feat(platform-wallet): testnet verification harness for the DPNS marketplace; self-registering contract caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/dpns_marketplace_testnet.rs — discover phase (HD identity map, key-layout probe for an external identity id) and run phase exercising the full wallet-level flow against real testnet: register -> list -> re-price -> typed stale-price rejection -> purchase by a second identity (owner/records/label reconciliation) -> history timeline -> typed not-for-sale -> re-list -> delist via transfer-to-self with $price verified cleared. All checks green 2026-08-09; transcript recorded in docs/DPNS_MARKETPLACE.md §9. Verification-driven fixes: - register_name_with_external_signer now fetches and registers the DPNS contract with the context provider BEFORE broadcasting — without it, hosts that never seed known contracts fail post-broadcast proof verification ("unknown contract ... in document verification") even though the registration landed on-chain. - the marketplace DPNS / Document History contract caches now hold the on-chain FETCHED contract (via fetch_contract_arc_for_document_op, which also registers it with the provider) instead of the bundled system contract, so query proof verification matches the network's active contract version and works on unseeded hosts. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet/Cargo.toml | 3 + .../docs/DPNS_MARKETPLACE.md | 36 +- .../examples/dpns_marketplace_testnet.rs | 594 ++++++++++++++++++ .../src/wallet/identity/network/document.rs | 4 +- .../src/wallet/identity/network/dpns.rs | 10 + .../identity/network/dpns_marketplace.rs | 125 ++-- 6 files changed, 712 insertions(+), 60 deletions(-) create mode 100644 packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index dacd11b0ef7..7d404f6985a 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -100,6 +100,9 @@ name = "shielded_chunk_timing_bench" required-features = ["shielded"] [dev-dependencies] +# In-process Signer for the manual testnet +# verification example (`examples/dpns_marketplace_testnet.rs`). +simple-signer = { path = "../simple-signer", features = ["state-transitions"] } # Used by `examples/shielded_chunk_timing_bench.rs` and # `tests/shielded_decrypt_bench.rs` to assemble per-chunk wire # fixtures and decode the `ShieldedEncryptedNote` wire type. diff --git a/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md b/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md index 7740cbc6e5a..4a5f8c8dc3f 100644 --- a/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md +++ b/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md @@ -260,4 +260,38 @@ be derived from `priceUpdate` documents by `$createdAt` if the app wants it. ## 9. Testnet verification results -_Pending — to be filled in by the verification run._ +Run 2026-08-09 via `examples/dpns_marketplace_testnet.rs` (phase `run`) +against testnet DAPI, seller = wallet HD identity index 1, buyer = index 3 +(distinct identities, same wallet — exercising both buyer- and seller-side +reconciliation). Every check passed: + +| Step | Result | +|---|---| +| register `mktp1786261653test.dash` (uncontested) on seller | PASS | +| `set_dpns_name_price` 1,000,000 credits — confirmed doc + fresh query | PASS | +| re-price to 2,000,000 credits | PASS | +| purchase at stale price → typed `DocumentPriceChanged{expected:1M, actual:2M}` (pre-broadcast) | PASS | +| `purchase_dpns_name` at 2M → owner flips to buyer | PASS | +| purchase clears `$price` on the confirmed document | PASS | +| protocol rewrote `records.identity` to the buyer | PASS | +| local marketplace row → buyer / `Owned` | PASS | +| `dpns_name_history` → `Registered`, `PriceSet(1M)` @503688, `PriceSet(2M)` @503689, `Purchased{2M, seller, buyer}` @503690 — ordered, with block heights | PASS | +| purchase of unlisted name → typed `DocumentNotForSale` | PASS | +| re-list 3M then `delist_dpns_name` (transfer-to-self) → confirmed doc `$price=None`, owner unchanged | PASS | +| fresh query after delist → `$price=None` (with bounded lagging-replica retry) | PASS | +| `search_dpns_names_with_state` prefix search finds the name | PASS | +| `sync_dpns_marketplace` pass → 6 names tracked, no spurious deltas | PASS | + +Findings folded back into the implementation during verification: + +- `Sdk::register_dpns_name` never registers the DPNS contract with the + context provider, so on hosts that don't pre-seed known contracts the + post-broadcast proof fails with "unknown contract … in document + verification" **after the registration landed**. Fixed: + `register_name_with_external_signer` now fetches+registers the contract + first, and the marketplace contract caches hold the **on-chain fetched** + contract (registered with the provider) rather than the bundled one. +- Fresh reads right after a broadcast can race a lagging replica (a + banned/slow node serving the previous block). The confirmed document + returned by each transition is the authoritative proof-verified state; + UI-level re-reads should tolerate one block of replica lag. diff --git a/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs new file mode 100644 index 00000000000..0c442b95bed --- /dev/null +++ b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs @@ -0,0 +1,594 @@ +//! Manual TESTNET verification harness for the DPNS username-marketplace +//! wallet layer (`wallet/identity/network/dpns_marketplace.rs`). +//! +//! Exercises the real wallet-level flow end to end against testnet DAPI: +//! register (uncontested) name → list → verify sale state → re-price → +//! typed stale-price rejection → purchase by a second identity → +//! ownership/records/label reconciliation → history timeline (priceSet ×2 + +//! purchased) → re-list → delist via transfer-to-self → `$price` cleared. +//! Results feed `docs/DPNS_MARKETPLACE.md` §9. +//! +//! Environment (secrets stay in env, never printed): +//! DPNS_MNEMONIC required — wallet recovery phrase +//! DPNS_PHASE "discover" (default) or "run" +//! DPNS_SELLER_INDEX HD identity index of the seller (default 0) +//! DPNS_BUYER_INDEX HD identity index of the buyer (default 1) +//! DPNS_IDENTITY_ID optional base58 id: discover also reports which HD +//! index (0..=9) derives this identity's keys, or that +//! none does (out-of-wallet key layout) +//! DPNS_PRIVATE_KEY optional single signing key (hex or WIF) fallback +//! when the identity's keys are not HD-derived; used +//! with DPNS_IDENTITY_ID +//! DPNS_DAPI_ADDRESSES optional comma-separated https://host:port list +//! +//! Run: +//! DPNS_MNEMONIC="…" cargo run -p platform-wallet --example dpns_marketplace_testnet +//! DPNS_PHASE=run DPNS_MNEMONIC="…" cargo run -p platform-wallet --example dpns_marketplace_testnet + +use std::sync::Arc; + +use dash_sdk::sdk::{Address, AddressList}; +use dash_sdk::SdkBuilder; +use dashcore::hashes::{hash160, Hash}; +use dashcore::Network; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, KeyType}; +use dpp::prelude::Identifier; +use key_wallet::bip32::ExtendedPrivKey; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use platform_wallet::changeset::{ + ClientStartState, DpnsNameSaleStatus, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::identity::network::{ + derive_ecdsa_identity_auth_keypair_from_master, DpnsNameHistoryEventKind, IdentityWallet, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; +use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; +use simple_signer::signer::SimpleSigner; + +/// Testnet DAPI evonodes (same set as `tests/spv_sync.rs`); override +/// with `DPNS_DAPI_ADDRESSES`. +const TESTNET_DAPI_ADDRESSES: &[&str] = &[ + "https://68.67.122.1:1443", + "https://68.67.122.2:1443", + "https://68.67.122.3:1443", +]; + +/// Listing prices for the flow (credits). Small on purpose — the point +/// is the protocol semantics, not the amounts. +const PRICE_INITIAL: Credits = 1_000_000; +const PRICE_FINAL: Credits = 2_000_000; +const PRICE_RELIST: Credits = 3_000_000; +/// Buyer top-up floor: purchase price + the wallet's fee reserve with +/// headroom for the buyer's own later transitions (re-list + delist). +const BUYER_MIN_CREDITS: Credits = 500_000_000; +const BUYER_TOP_UP: Credits = 1_000_000_000; + +struct NoopPersister; +impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), platform_wallet::changeset::PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + fn flush( + &self, + _wallet_id: WalletId, + ) -> Result<(), platform_wallet::changeset::PersistenceError> { + Ok(()) + } +} + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn dapi_addresses() -> AddressList { + let raw = std::env::var("DPNS_DAPI_ADDRESSES").unwrap_or_default(); + let addrs: Vec

= if raw.trim().is_empty() { + TESTNET_DAPI_ADDRESSES + .iter() + .filter_map(|s| s.parse().ok()) + .collect() + } else { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse().ok()) + .collect() + }; + assert!(!addrs.is_empty(), "no DAPI addresses configured"); + AddressList::from_iter(addrs) +} + +/// Whether `sk_bytes` is the private key for `key` (33-byte pubkey for +/// ECDSA_SECP256K1, hash160 for ECDSA_HASH160). +fn private_key_matches( + key: &dpp::identity::IdentityPublicKey, + sk_bytes: &[u8; 32], +) -> bool { + let secp = dashcore::secp256k1::Secp256k1::new(); + let Ok(sk) = dashcore::secp256k1::SecretKey::from_byte_array(sk_bytes) else { + return false; + }; + let pubkey = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &sk).serialize(); + match key.key_type() { + KeyType::ECDSA_SECP256K1 => key.data().as_slice() == pubkey.as_slice(), + KeyType::ECDSA_HASH160 => { + key.data().as_slice() == hash160::Hash::hash(&pubkey).as_byte_array().as_slice() + } + _ => false, + } +} + +/// Load every ECDSA key of `identity` (HD index `identity_index`, +/// derivation convention `key_index == key_id`) into `signer`, verifying +/// each derived pubkey against the on-chain key before insertion. +/// Returns how many keys matched. +fn load_hd_keys_into_signer( + signer: &mut SimpleSigner, + identity: &Identity, + identity_index: u32, + master: &ExtendedPrivKey, +) -> u32 { + let mut matched = 0; + for (key_id, ipk) in identity.public_keys() { + if !matches!( + ipk.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ) { + continue; + } + let Ok(kp) = derive_ecdsa_identity_auth_keypair_from_master( + master, + key_wallet::Network::Testnet, + identity_index, + *key_id, + ) else { + continue; + }; + if private_key_matches(ipk, &kp.private_key) { + signer.add_identity_public_key(ipk.clone(), *kp.private_key); + matched += 1; + } + } + matched +} + +fn parse_private_key(raw: &str) -> Option<[u8; 32]> { + let trimmed = raw.trim(); + if let Ok(bytes) = hex::decode(trimmed) { + if bytes.len() == 32 { + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + return Some(out); + } + } + dashcore::PrivateKey::from_wif(trimmed) + .ok() + .map(|pk| pk.inner.secret_bytes()) +} + +async fn discover( + idw: &IdentityWallet, + master: &ExtendedPrivKey, + sdk: &Arc, +) -> Result<(), Box> { + println!("== discover: HD identities (index 0..=9) =="); + for index in 0..10u32 { + match idw.load_identity_by_index_from_master(index, master).await { + Ok(Some(identity)) => { + let key_summary: Vec = identity + .public_keys() + .iter() + .map(|(id, k)| { + format!( + "#{id}:{:?}/{:?}/{:?}", + k.purpose(), + k.security_level(), + k.key_type() + ) + }) + .collect(); + println!( + "index {index}: {} balance={} keys=[{}]", + identity.id(), + identity.balance(), + key_summary.join(", ") + ); + } + Ok(None) => println!("index {index}: (none)"), + Err(e) => println!("index {index}: lookup error: {e}"), + } + } + + if let Ok(raw_id) = std::env::var("DPNS_IDENTITY_ID") { + use dash_sdk::platform::Fetch; + let id = Identifier::from_string( + raw_id.trim(), + dpp::platform_value::string_encoding::Encoding::Base58, + )?; + println!("== discover: key layout of {id} =="); + let Some(identity) = Identity::fetch(sdk.as_ref(), id).await? else { + println!("identity not found on testnet"); + return Ok(()); + }; + println!("balance={} keys={}", identity.balance(), identity.public_keys().len()); + let mut any = false; + for index in 0..10u32 { + let mut probe = SimpleSigner::default(); + let matched = load_hd_keys_into_signer(&mut probe, &identity, index, master); + if matched > 0 { + println!("HD index {index}: {matched} key(s) derive from this mnemonic"); + any = true; + } + } + if !any { + println!("no key on this identity derives from the mnemonic (indexes 0..=9)"); + } + if let Ok(raw_sk) = std::env::var("DPNS_PRIVATE_KEY") { + match parse_private_key(&raw_sk) { + Some(sk) => { + let matches: Vec = identity + .public_keys() + .iter() + .filter(|(_, k)| private_key_matches(k, &sk)) + .map(|(kid, k)| { + format!("#{kid} ({:?}/{:?})", k.purpose(), k.security_level()) + }) + .collect(); + println!( + "DPNS_PRIVATE_KEY matches keys: [{}]", + if matches.is_empty() { + "none".to_string() + } else { + matches.join(", ") + } + ); + } + None => println!("DPNS_PRIVATE_KEY did not parse as hex or WIF"), + } + } + } + Ok(()) +} + + +/// Re-read `label`'s on-chain state until `predicate` holds or attempts +/// run out. Fresh reads race lagging replicas — a query right after a +/// broadcast can land on a node one block behind, briefly serving the +/// pre-transition document. The proof-verified CONFIRMED document from +/// the transition is the authoritative check; these visibility re-reads +/// are the "and other clients can see it" bonus, so they tolerate +/// replica lag with a bounded retry. +async fn wait_for_visible_state( + idw: &IdentityWallet, + label: &str, + predicate: impl Fn(&platform_wallet::wallet::identity::network::DpnsDomainState) -> bool, +) -> Result> +{ + let mut last = None; + for _ in 0..6 { + if let Some(state) = idw.dpns_name_state(label).await? { + if predicate(&state) { + return Ok(state); + } + last = Some(state); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + Ok(last.ok_or("name never became visible")?) +} + +/// Assert helper that prints a PASS line (the run transcript is the +/// verification artifact). +fn check(name: &str, ok: bool, detail: impl std::fmt::Display) { + if ok { + println!("PASS {name}: {detail}"); + } else { + println!("FAIL {name}: {detail}"); + panic!("verification step failed: {name}"); + } +} + +#[allow(clippy::too_many_lines)] +async fn run_flow( + idw: &IdentityWallet, + master: &ExtendedPrivKey, +) -> Result<(), Box> { + let seller_index: u32 = std::env::var("DPNS_SELLER_INDEX") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let buyer_index: u32 = std::env::var("DPNS_BUYER_INDEX") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + + // `_from_master`: the manager-created wallet is external-signable + // (no seed retained Rust-side), so the plain by-index loader cannot + // derive the lookup key hash; the master-xpriv variant exists for + // exactly this shape. + let seller = idw + .load_identity_by_index_from_master(seller_index, master) + .await? + .ok_or_else(|| format!("no identity at seller index {seller_index}"))?; + let buyer = idw + .load_identity_by_index_from_master(buyer_index, master) + .await? + .ok_or_else(|| format!("no identity at buyer index {buyer_index}"))?; + let seller_id = seller.id(); + let buyer_id = buyer.id(); + println!("seller (index {seller_index}): {seller_id} balance={}", seller.balance()); + println!("buyer (index {buyer_index}): {buyer_id} balance={}", buyer.balance()); + + let mut signer = SimpleSigner::default(); + let seller_keys = load_hd_keys_into_signer(&mut signer, &seller, seller_index, master); + let buyer_keys = load_hd_keys_into_signer(&mut signer, &buyer, buyer_index, master); + check( + "signer-keys", + seller_keys > 0 && buyer_keys > 0, + format!("seller {seller_keys} key(s), buyer {buyer_keys} key(s) HD-derived"), + ); + + // Buyer must afford price + fee reserve (plus its own later + // transitions); top up from the seller when short. + if buyer.balance() < BUYER_MIN_CREDITS { + println!( + "buyer balance {} < {BUYER_MIN_CREDITS}, transferring {BUYER_TOP_UP} credits from seller", + buyer.balance() + ); + idw.transfer_credits_with_external_signer(&seller_id, &buyer_id, BUYER_TOP_UP, &signer, None) + .await?; + idw.refresh_identity(&buyer_id).await?; + } + + // Fresh uncontested label per run: contains digits (timestamp) so it + // never enters a masternode vote. + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let label = format!("mktp{unix}test"); + println!("== registering test name {label:?} on seller =="); + let full_name = idw + .register_name_with_external_signer(&seller_id, &label, &signer) + .await?; + check("register", full_name.ends_with(".dash"), &full_name); + + // 1. List. + let listed = idw + .set_dpns_name_price(&seller_id, &label, PRICE_INITIAL, &signer) + .await?; + check( + "list", + listed.price == Some(PRICE_INITIAL), + format!("confirmed $price={:?}", listed.price), + ); + let fresh = wait_for_visible_state(idw, &label, |s| { + s.price == Some(PRICE_INITIAL) && s.owner_id == seller_id + }) + .await?; + check( + "list-visible", + fresh.price == Some(PRICE_INITIAL) && fresh.owner_id == seller_id, + format!("on-chain $price={:?} owner={}", fresh.price, fresh.owner_id), + ); + + // 2. Re-price. + let repriced = idw + .set_dpns_name_price(&seller_id, &label, PRICE_FINAL, &signer) + .await?; + check( + "re-price", + repriced.price == Some(PRICE_FINAL), + format!("confirmed $price={:?}", repriced.price), + ); + + // 3. Typed stale-price rejection (pre-flight, before any broadcast). + let stale = idw + .purchase_dpns_name(&buyer_id, &label, PRICE_INITIAL, &signer) + .await; + check( + "stale-price-typed", + matches!( + stale, + Err(PlatformWalletError::DocumentPriceChanged { expected, actual, .. }) + if expected == PRICE_INITIAL && actual == PRICE_FINAL + ), + format!("{stale:?}"), + ); + + // 4. Purchase at the confirmed price. + idw.refresh_identity(&buyer_id).await?; + let bought = idw + .purchase_dpns_name(&buyer_id, &label, PRICE_FINAL, &signer) + .await?; + check( + "purchase-owner", + bought.owner_id == buyer_id, + format!("owner={}", bought.owner_id), + ); + check( + "purchase-clears-price", + bought.price.is_none(), + format!("$price={:?}", bought.price), + ); + check( + "purchase-rewrites-records", + bought.records_identity_id == Some(buyer_id), + format!("records.identity={:?}", bought.records_identity_id), + ); + + // 5. Local reconciliation: label moved seller → buyer; marketplace row + // tracks the buyer as Owned. + let rows = idw.local_dpns_name_states(None).await?; + let row = rows + .iter() + .find(|r| r.normalized_label == bought.normalized_label) + .expect("marketplace row for purchased name"); + check( + "local-row", + row.wallet_identity_id == buyer_id && row.status == DpnsNameSaleStatus::Owned, + format!("row identity={} status={:?}", row.wallet_identity_id, row.status), + ); + + // 6. History: Registered + PriceSet(1M) + PriceSet(2M) + Purchased(2M). + let history = idw.dpns_name_history(&label).await?; + println!("history ({} events):", history.len()); + for event in &history { + println!(" {:?}", event); + } + let price_sets: Vec = history + .iter() + .filter_map(|e| match e.kind { + DpnsNameHistoryEventKind::PriceSet { price } => Some(price), + _ => None, + }) + .collect(); + let purchased = history.iter().any(|e| { + matches!( + e.kind, + DpnsNameHistoryEventKind::Purchased { price, seller, buyer } + if price == PRICE_FINAL && seller == seller_id && buyer == buyer_id + ) + }); + check( + "history-price-sets", + price_sets == vec![PRICE_INITIAL, PRICE_FINAL], + format!("{price_sets:?}"), + ); + check("history-purchase", purchased, "purchase event with price+parties"); + check( + "history-registered", + matches!( + history.first().map(|e| &e.kind), + Some(DpnsNameHistoryEventKind::Registered) + ), + "timeline starts at registration", + ); + + // 7. Typed not-for-sale rejection now that the purchase cleared $price. + let not_for_sale = idw + .purchase_dpns_name(&seller_id, &label, PRICE_FINAL, &signer) + .await; + check( + "not-for-sale-typed", + matches!( + not_for_sale, + Err(PlatformWalletError::DocumentNotForSale { .. }) + ), + format!("{not_for_sale:?}"), + ); + + // 8. Delist: buyer re-lists, then delists via transfer-to-self; the + // method itself verifies the confirmed document cleared $price. + idw.set_dpns_name_price(&buyer_id, &label, PRICE_RELIST, &signer) + .await?; + let delisted = idw.delist_dpns_name(&buyer_id, &label, &signer).await?; + check( + "delist-clears-price", + delisted.price.is_none() && delisted.owner_id == buyer_id, + format!("$price={:?} owner={}", delisted.price, delisted.owner_id), + ); + let fresh = + wait_for_visible_state(idw, &label, |s| s.price.is_none() && s.owner_id == buyer_id) + .await?; + check( + "delist-visible", + fresh.price.is_none() && fresh.owner_id == buyer_id, + format!("on-chain $price={:?} owner={}", fresh.price, fresh.owner_id), + ); + + // 9. Search + sync passes for completeness. + let results = idw + .search_dpns_names_with_state("mktp", Some(50), None) + .await?; + check( + "search", + results.iter().any(|s| s.normalized_label == fresh.normalized_label), + format!("{} result(s) for prefix", results.len()), + ); + let summary = idw.sync_dpns_marketplace().await?; + println!( + "sync summary: tracked={} added={:?} departed={} prices_changed={}", + summary.names_tracked, + summary.names_added.len(), + summary.names_departed.len(), + summary.prices_changed.len() + ); + + println!("== ALL CHECKS PASSED =="); + Ok(()) +} + +async fn run() -> Result<(), Box> { + let phrase = std::env::var("DPNS_MNEMONIC") + .map_err(|_| "DPNS_MNEMONIC env var is required (never printed)")?; + + let addresses = dapi_addresses(); + let provider = TrustedHttpContextProvider::new( + Network::Testnet, + None, + std::num::NonZeroUsize::new(100).unwrap(), + )?; + let sdk = Arc::new( + SdkBuilder::new(addresses) + .with_network(Network::Testnet) + .with_context_provider(provider) + .build()?, + ); + + let manager = Arc::new(PlatformWalletManager::new( + Arc::clone(&sdk), + Arc::new(NoopPersister), + Arc::new(NoopEventHandler), + )); + let wallet = manager + .create_wallet_from_mnemonic( + &phrase, + Network::Testnet, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await?; + let idw = wallet.identity(); + + let mnemonic: key_wallet::Mnemonic = phrase.parse()?; + let master = ExtendedPrivKey::new_master(key_wallet::Network::Testnet, &mnemonic.to_seed(""))?; + + match std::env::var("DPNS_PHASE").as_deref() { + Ok("run") => run_flow(idw, &master).await, + _ => discover(idw, &master, &sdk).await, + } +} + +fn main() { + let _ = tracing_subscriber::FmtSubscriber::builder() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .try_init(); + + // 8 MiB worker stacks: every marketplace op verifies GroveDB + // document-query proofs, whose recursion overflows the 2 MiB tokio + // default (same rationale as DASHPAY_SYNC_STACK_BYTES). + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(8 * 1024 * 1024) + .enable_all() + .build() + .expect("build runtime") + .block_on(run()) + .expect("verification run failed"); +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index 53648ec4fc4..be3a76caa1f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -137,7 +137,7 @@ impl IdentityWallet { /// returns `None` for the contract and proof verification fails with /// "unknown contract ... in document verification", even though the /// write landed on-chain. - fn register_contract_for_proof_verification(&self, contract: &DataContract) { + pub(super) fn register_contract_for_proof_verification(&self, contract: &DataContract) { if let Some(provider) = self.sdk.context_provider() { provider.register_data_contract(Arc::new(contract.clone())); } @@ -330,7 +330,7 @@ impl IdentityWallet { /// transfer / set-price / purchase) — each needs the contract as an /// `Arc` for both the single-document fetch query and /// the transition builder. - async fn fetch_contract_arc_for_document_op( + pub(super) async fn fetch_contract_arc_for_document_op( &self, contract_id: &Identifier, document_type_name: &str, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index e0dc877bf5d..5e6476c1af3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -184,6 +184,16 @@ impl IdentityWallet { { use dash_sdk::platform::dpns_usernames::RegisterDpnsNameInput; + // Ensure the on-chain DPNS contract is fetched and registered + // with the SDK's context provider BEFORE broadcasting: the + // post-broadcast proof of the preorder/domain documents needs it, + // `Sdk::register_dpns_name` never registers it back, and a host + // that doesn't pre-seed known contracts (e.g. a headless + // consumer) would otherwise fail proof verification with + // "unknown contract ... in document verification" even though + // the registration landed on-chain. + self.dpns_contract().await?; + let (identity, auth_key) = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 1db474b9ff2..383af6e1e12 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -279,49 +279,66 @@ impl DpnsMarketplaceSyncSummary { // Contract caches // --------------------------------------------------------------------------- -/// Process-wide cached DPNS data contract (bundled system contract, same -/// caching rationale as [`super::dashpay_contract`]). Used for *query -/// building*; the trade transitions keep fetching the on-chain contract -/// through `fetch_contract_arc_for_document_op` (which also registers it -/// for post-broadcast proof verification). -pub(crate) fn dpns_contract() -> Result, PlatformWalletError> { - static CONTRACT: std::sync::OnceLock> = std::sync::OnceLock::new(); - if let Some(contract) = CONTRACT.get() { - return Ok(Arc::clone(contract)); - } - let contract = dpp::system_data_contracts::load_system_data_contract( - dpp::data_contracts::SystemDataContract::DPNS, - dpp::version::PlatformVersion::latest(), - ) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!("Failed to load DPNS contract: {e}")) - })?; - let arc = Arc::new(contract); - let _ = CONTRACT.set(Arc::clone(&arc)); - Ok(CONTRACT.get().map(Arc::clone).unwrap_or(arc)) +/// The DPNS system contract id (fixed across contract versions). +fn dpns_contract_id() -> Identifier { + dpp::data_contracts::SystemDataContract::DPNS.id() } -/// Process-wide cached Document History system contract — the event log -/// DPNS v2's `keeps*History` flags write `transfer` / `purchase` / -/// `priceUpdate` documents into. NOT the GroveDB `documentsKeepHistory` -/// mechanism (`getDocumentHistory` returns empty for DPNS). -pub(crate) fn document_history_contract() -> Result, PlatformWalletError> { - static CONTRACT: std::sync::OnceLock> = std::sync::OnceLock::new(); - if let Some(contract) = CONTRACT.get() { - return Ok(Arc::clone(contract)); +/// The Document History system contract id. +fn document_history_contract_id() -> Identifier { + dpp::data_contracts::SystemDataContract::DocumentHistory.id() +} + +impl IdentityWallet { + /// Process-wide cached DPNS data contract, fetched ON-CHAIN once via + /// `fetch_contract_arc_for_document_op` — which also registers it + /// with the SDK's context provider so document-query and + /// post-broadcast proof verification can resolve it. Fetching (vs + /// the bundled system contract) guarantees the schema matches the + /// network's active contract version, and makes the marketplace + /// self-sufficient on hosts that never seed the trusted provider's + /// known-contracts list. + pub(crate) async fn dpns_contract(&self) -> Result, PlatformWalletError> { + static CONTRACT: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(contract) = CONTRACT.get() { + // Re-register on cache hits too: the context provider can be + // swapped/reset across SDK reconnects while this static + // outlives it. Registration is an idempotent map insert. + self.register_contract_for_proof_verification(contract); + return Ok(Arc::clone(contract)); + } + let contract = self + .fetch_contract_arc_for_document_op(&dpns_contract_id(), DPNS_DOCUMENT_TYPE) + .await?; + // A concurrent first call may have won the race — return + // whichever Arc actually landed in the cell. + let _ = CONTRACT.set(Arc::clone(&contract)); + Ok(CONTRACT.get().map(Arc::clone).unwrap_or(contract)) + } + + /// Process-wide cached Document History system contract — the event + /// log DPNS v2's `keeps*History` flags write `transfer` / `purchase` + /// / `priceUpdate` documents into. NOT the GroveDB + /// `documentsKeepHistory` mechanism (`getDocumentHistory` returns + /// empty for DPNS). Same on-chain fetch + provider registration as + /// [`Self::dpns_contract`]. + pub(crate) async fn document_history_contract( + &self, + ) -> Result, PlatformWalletError> { + static CONTRACT: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(contract) = CONTRACT.get() { + self.register_contract_for_proof_verification(contract); + return Ok(Arc::clone(contract)); + } + let contract = self + .fetch_contract_arc_for_document_op( + &document_history_contract_id(), + HISTORY_TYPE_TRANSFER, + ) + .await?; + let _ = CONTRACT.set(Arc::clone(&contract)); + Ok(CONTRACT.get().map(Arc::clone).unwrap_or(contract)) } - let contract = dpp::system_data_contracts::load_system_data_contract( - dpp::data_contracts::SystemDataContract::DocumentHistory, - dpp::version::PlatformVersion::latest(), - ) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to load Document History contract: {e}" - )) - })?; - let arc = Arc::new(contract); - let _ = CONTRACT.set(Arc::clone(&arc)); - Ok(CONTRACT.get().map(Arc::clone).unwrap_or(arc)) } // --------------------------------------------------------------------------- @@ -362,7 +379,7 @@ impl IdentityWallet { limit: Option, start_after: Option, ) -> Result, PlatformWalletError> { - let contract = dpns_contract()?; + let contract = self.dpns_contract().await?; let normalized_prefix = convert_to_homograph_safe_chars(dpns_label(prefix)); let mut where_clauses = vec![WhereClause { field: "normalizedParentDomainName".to_string(), @@ -400,7 +417,7 @@ impl IdentityWallet { &self, name: &str, ) -> Result, PlatformWalletError> { - let contract = dpns_contract()?; + let contract = self.dpns_contract().await?; let normalized = convert_to_homograph_safe_chars(dpns_label(name)); if normalized.is_empty() { return Err(PlatformWalletError::InvalidParameter( @@ -442,7 +459,7 @@ impl IdentityWallet { identity_id: &Identifier, limit: Option, ) -> Result, PlatformWalletError> { - let contract = dpns_contract()?; + let contract = self.dpns_contract().await?; let query = DocumentQuery { select: SelectProjection::documents(), data_contract: contract, @@ -554,7 +571,7 @@ impl IdentityWallet { &self, identity_id: &Identifier, ) -> Result { - let contract = dpns_contract()?; + let contract = self.dpns_contract().await?; let required_level = contract .document_type_for_name(DPNS_DOCUMENT_TYPE) .map_err(|e| { @@ -717,7 +734,7 @@ impl IdentityWallet { ))); } let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; - let contract_id = dpns_contract()?.id(); + let contract_id = dpns_contract_id(); let confirmed = self .set_document_price_with_signer( owner_identity_id, @@ -768,7 +785,7 @@ impl IdentityWallet { }); } let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; - let contract_id = dpns_contract()?.id(); + let contract_id = dpns_contract_id(); let confirmed = self .transfer_document_with_signer( owner_identity_id, @@ -828,7 +845,7 @@ impl IdentityWallet { ))); } let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; - let contract_id = dpns_contract()?.id(); + let contract_id = dpns_contract_id(); let confirmed = self .transfer_document_with_signer( owner_identity_id, @@ -948,7 +965,7 @@ impl IdentityWallet { }); } let signing_key = self.select_dpns_signing_key(purchaser_identity_id).await?; - let contract_id = dpns_contract()?.id(); + let contract_id = dpns_contract_id(); let confirmed = self .purchase_document_with_signer( purchaser_identity_id, @@ -1051,7 +1068,7 @@ impl IdentityWallet { document_id: &Identifier, registered_at_ms: Option, ) -> Result, PlatformWalletError> { - let dpns_contract_id = dpns_contract()?.id(); + let dpns_contract_id = dpns_contract_id(); let mut events: Vec = Vec::new(); if let Some(at_ms) = registered_at_ms { events.push(DpnsNameHistoryEvent { @@ -1084,7 +1101,7 @@ impl IdentityWallet { source_document_id: &Identifier, history_doc_type: &str, ) -> Result, PlatformWalletError> { - let contract = document_history_contract()?; + let contract = self.document_history_contract().await?; let query = DocumentQuery { select: SelectProjection::documents(), data_contract: contract, @@ -1321,14 +1338,8 @@ impl IdentityWallet { new_owner: &Identifier, domain_transferred_at_ms: Option, ) -> DpnsNameSaleStatus { - let dpns_contract_id = match dpns_contract() { - Ok(c) => c.id(), - Err(_) => { - return DpnsNameSaleStatus::Transferred { to: *new_owner }; - } - }; let purchases = match self - .fetch_history_documents(&dpns_contract_id, document_id, HISTORY_TYPE_PURCHASE) + .fetch_history_documents(&dpns_contract_id(), document_id, HISTORY_TYPE_PURCHASE) .await { Ok(docs) => docs, From 748159e662cdca932e9b16025084713bcf84c9a5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 15:00:18 +0700 Subject: [PATCH 03/13] feat(platform-wallet-ffi,swift-sdk): DPNS marketplace FFI surface and Swift wrappers FFI (rs-platform-wallet-ffi): - error codes 37-40 (DocumentNotForSale, DocumentPriceChanged, InsufficientIdentityCredits, ContestedNameNotTradable) with stable JSON detail payloads in the result message so hosts recover typed values; DpnsNameNotFound maps to NotFound (98) - dpns_marketplace.rs: search / name-state / my-names / set-price / delist / transfer / purchase / history / sync entry points over the wallet layer, with pointer-only repr(C) rows and paired destructors - dpns_sync.rs: manager-handle start/stop/sync-now/interval wrappers for the DpnsSyncManager coordinator - persistence: on_persist_dpns_name_states_fn vtable slot (appended, ABI-additive) + DpnsNameStateFFI mirror rows + DPNS_NAME_STATES capability dispatch swift-sdk: - PlatformWalletResultCode/PlatformWalletError mirrors incl. typed priceChanged/insufficientIdentityCredits/contestedNameNotTradable cases decoded from the JSON detail - DpnsMarketplace.swift: DpnsMarketplaceName / DpnsNameStateRow / DpnsNameHistoryEvent value types + ManagedPlatformWallet methods (searchDpnsMarketplace, dpnsMarketplaceNameState, myDpnsMarketplaceNames, setDpnsNamePrice, delistDpnsName, transferDpnsName, purchaseDpnsName, dpnsNameHistory, syncDpnsMarketplace) - DpnsSyncManager Swift wrappers; PersistentDPNSName grows marketplace columns (documentId, price, sale status, counterparty) fed by the new persister callback; marketplace columns are meaningful only while documentIdBase58 != nil (clear-don't-delete: the row is shared with the identity label cache) Verified: cargo test -p platform-wallet-ffi (290 tests) and -p platform-wallet (752) green; build_ios.sh --target mac + swift build clean. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + .../src/dpns_marketplace.rs | 1169 +++++++++++++++++ .../src/dpns_name_state_persistence.rs | 272 ++++ .../rs-platform-wallet-ffi/src/dpns_sync.rs | 245 ++++ packages/rs-platform-wallet-ffi/src/error.rs | 292 ++++ packages/rs-platform-wallet-ffi/src/lib.rs | 6 + .../rs-platform-wallet-ffi/src/persistence.rs | 91 +- .../docs/DPNS_MARKETPLACE.md | 48 +- .../Persistence/DashModelContainer.swift | 10 + .../Models/PersistentDPNSName.swift | 93 ++ .../PlatformWallet/DpnsMarketplace.swift | 647 +++++++++ .../PlatformWalletManager.swift | 5 + .../PlatformWalletManagerDpnsSync.swift | 136 ++ .../PlatformWalletPersistenceHandler.swift | 249 ++++ .../PlatformWallet/PlatformWalletResult.swift | 151 +++ 15 files changed, 3394 insertions(+), 21 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs create mode 100644 packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs create mode 100644 packages/rs-platform-wallet-ffi/src/dpns_sync.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDpnsSync.swift diff --git a/Cargo.lock b/Cargo.lock index b107eb8886e..c3870ce0f24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5218,6 +5218,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "simple-signer", "static_assertions", "thiserror 1.0.69", "tokio", diff --git a/packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs b/packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs new file mode 100644 index 00000000000..1ef06e54799 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs @@ -0,0 +1,1169 @@ +//! FFI bindings for the DPNS username marketplace on the platform-wallet +//! [`IdentityWallet`](platform_wallet::IdentityWallet): search with sale +//! state, the local name-state rows, the four trade ops (list / delist / +//! transfer / purchase), the per-name trade history, and the on-demand +//! marketplace sync pass. +//! +//! Wallet-layer design record: +//! `rs-platform-wallet/docs/DPNS_MARKETPLACE.md`. The typed rejections +//! these entry points can return (`ErrorDocumentNotForSale`, +//! `ErrorDocumentPriceChanged`, `ErrorInsufficientIdentityCredits`, +//! `ErrorContestedNameNotTradable`) are documented on +//! [`PlatformWalletFFIResultCode`](crate::error::PlatformWalletFFIResultCode); +//! three of them carry a stable JSON detail object in the result message. +//! +//! Prices are **credits** everywhere on this boundary (1 duff = 1000 +//! credits). The duffs↔credits conversion is a host concern. +//! +//! Memory contract, matching the rest of the crate: every returned +//! pointer is Rust-owned and released by the paired `*_free` function in +//! this module — single values with +//! [`dpns_marketplace_name_free`], arrays with +//! [`dpns_marketplace_names_free`] / [`dpns_name_state_rows_free`] / +//! [`dpns_name_history_events_free`]. A single value and an array are +//! DIFFERENT allocations (a `Box` vs a `Box<[T]>`), so the free +//! functions are not interchangeable. Empty results are reported as +//! `null` + count `0` with a success code, never as an error. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +use dpp::prelude::Identifier; +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateEntry}; +use platform_wallet::wallet::identity::network::{ + DpnsDomainState, DpnsNameHistoryEvent, DpnsNameHistoryEventKind, +}; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::check_ptr; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::read_identifier; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +// --------------------------------------------------------------------------- +// Flat result structs +// --------------------------------------------------------------------------- + +/// A DPNS `domain` document read off Platform, with the marketplace +/// fields the plain name lookup drops: the document id every trade +/// transition needs, and `$price` (the sale state). +/// +/// `label` / `normalized_label` are heap-allocated NUL-terminated UTF-8 +/// owned by this struct. Release a single value with +/// [`dpns_marketplace_name_free`], an array with +/// [`dpns_marketplace_names_free`]. +/// +/// Optional fields travel as a `has_*` flag plus the value, never as a +/// sentinel: `has_price == false` means "not listed for sale", which is +/// a different fact from "listed at 0 credits". +#[repr(C)] +pub struct DpnsMarketplaceNameFFI { + /// The domain document id — stable across transfers and purchases. + pub document_id: [u8; 32], + /// The document's `$ownerId`: the identity that owns (and may sell) + /// the name. + pub owner_id: [u8; 32], + /// Whether `records_identity_id` is populated. + pub has_records_identity: bool, + /// `records.identity` — the identity the name resolves to. The + /// protocol rewrites it to the new owner on purchase/transfer. + /// Ignore unless `has_records_identity`. + pub records_identity_id: [u8; 32], + /// Display label, e.g. "Alice". + pub label: *mut c_char, + /// Homograph-normalized label, e.g. "a11ce". + pub normalized_label: *mut c_char, + /// Whether `price` is populated. `false` = the name is NOT for sale. + pub has_price: bool, + /// Listed sale price in credits (`$price`). Ignore unless `has_price`. + pub price: u64, + /// Document `$createdAt` in ms. `0` = unknown (the existing + /// convention on this boundary for absent document timestamps). + pub created_at_ms: u64, + /// Document `$updatedAt` in ms — bumps on price changes. `0` = unknown. + pub updated_at_ms: u64, + /// Document `$transferredAt` in ms — set on purchase/transfer. + /// `0` = unknown. + pub transferred_at_ms: u64, +} + +/// One locally persisted marketplace row: a name tracked for a wallet +/// identity, with its sale state and — for names that already left — +/// the counterparty. +/// +/// Distinct from [`DpnsMarketplaceNameFFI`]: this is the wallet's own +/// bookkeeping (no network read), so it carries `wallet_identity_id` / +/// `status` / `counterparty_id` instead of the live document's +/// `$ownerId` and `records.identity`. For a `Sold`/`Transferred` row +/// the current owner IS the counterparty; for an `Owned` row it is +/// `wallet_identity_id`. Release with [`dpns_name_state_rows_free`]. +#[repr(C)] +pub struct DpnsNameStateRowFFI { + /// The domain document id — this row's key. + pub document_id: [u8; 32], + /// The wallet identity this row is tracked for. For `Owned` rows the + /// document's `$ownerId`; for `Sold`/`Transferred` rows the previous + /// owner (ours). + pub wallet_identity_id: [u8; 32], + /// Display label, e.g. "Alice". + pub label: *mut c_char, + /// Homograph-normalized label, e.g. "a11ce". + pub normalized_label: *mut c_char, + /// Whether `price` is populated. `false` = not listed for sale. + pub has_price: bool, + /// Last-known listed sale price in credits. Ignore unless `has_price`. + pub price: u64, + /// Ownership status relative to `wallet_identity_id`: + /// `0` = owned, `1` = sold, `2` = transferred. + pub status: u8, + /// Whether `counterparty_id` is populated — true exactly when + /// `status != 0`. + pub has_counterparty: bool, + /// The buyer (`status == 1`) or recipient (`status == 2`). Ignore + /// unless `has_counterparty`. + pub counterparty_id: [u8; 32], + /// Document `$createdAt` in ms. `0` = unknown. + pub created_at_ms: u64, + /// Document `$updatedAt` in ms. `0` = unknown. + pub updated_at_ms: u64, + /// Document `$transferredAt` in ms. `0` = unknown. + pub transferred_at_ms: u64, + /// Wall-clock ms of the sync pass / confirmed transition that wrote + /// this row. + pub last_synced_at_ms: u64, +} + +/// One event in a name's trade timeline. All-POD (no owned strings), but +/// the array is still Rust-allocated — release it with +/// [`dpns_name_history_events_free`]. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct DpnsNameHistoryEventFFI { + /// What happened: `0` = registered, `1` = price set, `2` = purchased, + /// `3` = transferred. A transfer whose `from_id == to_id` is a + /// transfer-to-self delist. + pub kind: u8, + /// Block time of the event in ms. + pub at_ms: u64, + /// Whether `block_height` is populated. + pub has_block_height: bool, + /// Block height of the event. Ignore unless `has_block_height`. + pub block_height: u64, + /// Whether `price` is populated — true for `kind` 1 and 2. + pub has_price: bool, + /// Price in credits. Ignore unless `has_price`. + pub price: u64, + /// Whether `from_id` is populated — true for `kind` 2 and 3. + pub has_from: bool, + /// The seller (`kind == 2`) or sender (`kind == 3`). Ignore unless + /// `has_from`. + pub from_id: [u8; 32], + /// Whether `to_id` is populated — true for `kind` 2 and 3. + pub has_to: bool, + /// The buyer (`kind == 2`) or recipient (`kind == 3`). Ignore unless + /// `has_to`. + pub to_id: [u8; 32], +} + +// --------------------------------------------------------------------------- +// Conversions +// --------------------------------------------------------------------------- + +/// Heap-allocate `s` as an owned C string, or `null` if it contains an +/// interior NUL. Same fallback the DPNS label arrays use in +/// [`crate::dpns`] — the host reads a null label as an empty one rather +/// than losing the whole row. +fn owned_c_string(s: &str) -> *mut c_char { + CString::new(s).map(|c| c.into_raw()).unwrap_or(ptr::null_mut()) +} + +/// Release a C string produced by [`owned_c_string`] and null the slot, +/// so a second free is a no-op. +/// +/// # Safety +/// `slot` must be null or point at a `CString::into_raw` allocation. +unsafe fn free_owned_c_string(slot: &mut *mut c_char) { + if !slot.is_null() { + let _ = unsafe { CString::from_raw(*slot) }; + *slot = ptr::null_mut(); + } +} + +impl DpnsMarketplaceNameFFI { + /// Flatten a live domain state. Allocates both label strings. + fn from_state(state: &DpnsDomainState) -> Self { + let (has_records_identity, records_identity_id) = match state.records_identity_id { + Some(id) => (true, id.to_buffer()), + None => (false, [0u8; 32]), + }; + let (has_price, price) = match state.price { + Some(p) => (true, p), + None => (false, 0), + }; + Self { + document_id: state.document_id.to_buffer(), + owner_id: state.owner_id.to_buffer(), + has_records_identity, + records_identity_id, + label: owned_c_string(&state.label), + normalized_label: owned_c_string(&state.normalized_label), + has_price, + price, + created_at_ms: state.created_at_ms.unwrap_or(0), + updated_at_ms: state.updated_at_ms.unwrap_or(0), + transferred_at_ms: state.transferred_at_ms.unwrap_or(0), + } + } +} + +impl DpnsNameStateRowFFI { + /// Flatten a persisted marketplace row. Allocates both label strings. + fn from_entry(entry: &DpnsNameStateEntry) -> Self { + // Wildcard-free so a new status variant is a compile error rather + // than a silent mis-map (same discipline as `status_to_u8` in + // `invitation_persistence`). + let (status, has_counterparty, counterparty_id) = match entry.status { + DpnsNameSaleStatus::Owned => (0u8, false, [0u8; 32]), + DpnsNameSaleStatus::Sold { to } => (1u8, true, to.to_buffer()), + DpnsNameSaleStatus::Transferred { to } => (2u8, true, to.to_buffer()), + }; + let (has_price, price) = match entry.price { + Some(p) => (true, p), + None => (false, 0), + }; + Self { + document_id: entry.document_id.to_buffer(), + wallet_identity_id: entry.wallet_identity_id.to_buffer(), + label: owned_c_string(&entry.label), + normalized_label: owned_c_string(&entry.normalized_label), + has_price, + price, + status, + has_counterparty, + counterparty_id, + created_at_ms: entry.created_at_ms.unwrap_or(0), + updated_at_ms: entry.updated_at_ms.unwrap_or(0), + transferred_at_ms: entry.transferred_at_ms.unwrap_or(0), + last_synced_at_ms: entry.last_synced_at_ms, + } + } +} + +impl DpnsNameHistoryEventFFI { + /// Flatten one timeline event. All-POD — nothing to allocate. + fn from_event(event: &DpnsNameHistoryEvent) -> Self { + let mut out = Self { + kind: 0, + at_ms: event.at_ms, + has_block_height: event.block_height.is_some(), + block_height: event.block_height.unwrap_or(0), + has_price: false, + price: 0, + has_from: false, + from_id: [0u8; 32], + has_to: false, + to_id: [0u8; 32], + }; + // Wildcard-free: a new event kind must be mapped explicitly, not + // silently reported as a registration. + match event.kind { + DpnsNameHistoryEventKind::Registered => { + out.kind = 0; + } + DpnsNameHistoryEventKind::PriceSet { price } => { + out.kind = 1; + out.has_price = true; + out.price = price; + } + DpnsNameHistoryEventKind::Purchased { + price, + seller, + buyer, + } => { + out.kind = 2; + out.has_price = true; + out.price = price; + out.has_from = true; + out.from_id = seller.to_buffer(); + out.has_to = true; + out.to_id = buyer.to_buffer(); + } + DpnsNameHistoryEventKind::Transferred { from, to } => { + out.kind = 3; + out.has_from = true; + out.from_id = from.to_buffer(); + out.has_to = true; + out.to_id = to.to_buffer(); + } + } + out + } +} + +/// Move `values` into a heap array and publish it through the out-params. +/// An empty input writes `null` + `0` — an expected outcome, not an +/// error, and one the paired `*_free` tolerates. +/// +/// # Safety +/// `out_ptr` / `out_count` must be valid, writable, non-null. +unsafe fn publish_array(values: Vec, out_ptr: *mut *mut T, out_count: *mut usize) { + if values.is_empty() { + unsafe { + *out_ptr = ptr::null_mut(); + *out_count = 0; + } + return; + } + let count = values.len(); + let boxed = values.into_boxed_slice(); + unsafe { + *out_ptr = Box::into_raw(boxed) as *mut T; + *out_count = count; + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/// Search DPNS names by prefix, returning full domain state (document +/// id, owner, `$price`, timestamps) ordered by normalized label. +/// +/// An empty `prefix` is a valid alphabetical browse. `limit == 0` uses +/// the wallet's default page size. `start_after` is an optional 32-byte +/// cursor — pass the previous page's last `document_id` to continue, or +/// `null` for the first page. +/// +/// There is no server-side price filter or ordering: `$price` is not an +/// indexable system property (design doc §7), so the marketplace is +/// search-driven. Release the array with [`dpns_marketplace_names_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_search( + wallet_handle: Handle, + prefix: *const c_char, + limit: u32, + start_after: *const u8, + out_results: *mut *mut DpnsMarketplaceNameFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(prefix); + check_ptr!(out_results); + check_ptr!(out_count); + // Define the out-slots before any fallible work so an error return + // never leaves the caller holding stack garbage to free. + unsafe { + *out_results = ptr::null_mut(); + *out_count = 0; + } + + let prefix_str = + unwrap_result_or_return!(unsafe { CStr::from_ptr(prefix) }.to_str()).to_string(); + let limit_opt = if limit == 0 { None } else { Some(limit) }; + let start_after_id = if start_after.is_null() { + None + } else { + Some(unwrap_result_or_return!(unsafe { + read_identifier(start_after) + })) + }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + identity + .search_dpns_names_with_state(&prefix_str, limit_opt, start_after_id) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let states = unwrap_result_or_return!(result); + + let rows: Vec = + states.iter().map(DpnsMarketplaceNameFFI::from_state).collect(); + unsafe { publish_array(rows, out_results, out_count) }; + PlatformWalletFFIResult::ok() +} + +/// Fetch the authoritative marketplace state of a single DPNS name +/// (`"alice"` or `"alice.dash"`). +/// +/// A name that is not registered is an expected outcome, NOT an error: +/// the call succeeds with `*out_result == null`. Release a non-null +/// result with [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_name_state( + wallet_handle: Handle, + name: *const c_char, + out_result: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_result); + unsafe { *out_result = ptr::null_mut() }; + + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dpns_name_state(&name_str).await }) + }); + let result = unwrap_option_or_return!(option); + let state_opt = unwrap_result_or_return!(result); + if let Some(state) = state_opt { + let boxed = Box::new(DpnsMarketplaceNameFFI::from_state(&state)); + unsafe { *out_result = Box::into_raw(boxed) }; + } + PlatformWalletFFIResult::ok() +} + +/// Read this wallet's locally persisted marketplace rows — owned names +/// with their sale state, plus retained `Sold`/`Transferred` rows. +/// +/// Pass a 32-byte `identity_id` to filter to one wallet identity, or +/// `null` for every identity in the wallet. Reads the in-memory working +/// set: no network round-trip, so this is the cheap read behind a +/// "my names" screen. Release with [`dpns_name_state_rows_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_my_names( + wallet_handle: Handle, + identity_id: *const u8, + out_rows: *mut *mut DpnsNameStateRowFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_rows); + check_ptr!(out_count); + unsafe { + *out_rows = ptr::null_mut(); + *out_count = 0; + } + + let filter: Option = if identity_id.is_null() { + None + } else { + Some(unwrap_result_or_return!(unsafe { + read_identifier(identity_id) + })) + }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.local_dpns_name_states(filter.as_ref()).await }) + }); + let result = unwrap_option_or_return!(option); + let entries = unwrap_result_or_return!(result); + + let rows: Vec = + entries.iter().map(DpnsNameStateRowFFI::from_entry).collect(); + unsafe { publish_array(rows, out_rows, out_count) }; + PlatformWalletFFIResult::ok() +} + +/// The trade timeline of `name`: registration, price changes, purchases +/// (with price and counterparties), and transfers — merged and ordered +/// by block time ascending. +/// +/// Works for names that already left the wallet (the document id is then +/// taken from the local marketplace rows). An empty timeline writes +/// `null` + `0` with a success code. Release with +/// [`dpns_name_history_events_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_name_history( + wallet_handle: Handle, + name: *const c_char, + out_events: *mut *mut DpnsNameHistoryEventFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_events); + check_ptr!(out_count); + unsafe { + *out_events = ptr::null_mut(); + *out_count = 0; + } + + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dpns_name_history(&name_str).await }) + }); + let result = unwrap_option_or_return!(option); + let events = unwrap_result_or_return!(result); + + let rows: Vec = events + .iter() + .map(DpnsNameHistoryEventFFI::from_event) + .collect(); + unsafe { publish_array(rows, out_events, out_count) }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Trade operations +// --------------------------------------------------------------------------- + +/// List (or re-price) `name` for sale at `price_credits`. +/// +/// Goes through `IdentityWallet::set_dpns_name_price`: authoritative +/// name resolution (typed contested / not-found errors), ownership +/// check, automatic AUTHENTICATION + ECDSA signing-key selection on the +/// owner, broadcast, and a local sale-state write from the CONFIRMED +/// document. `out_state` receives that confirmed state — release it with +/// [`dpns_marketplace_name_free`]. +/// +/// `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx` (typically `KeychainSigner.handle`); +/// the caller retains ownership. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_set_name_price( + wallet_handle: Handle, + owner_identity_id: *const u8, + name: *const c_char, + price_credits: u64, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let owner_id = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + // Launder the signer handle across the `Send + 'static` future bound: + // the raw pointer is not `Send`, but the address is, and the signer is + // guaranteed alive for the whole synchronous call by the caller's + // ownership contract. Same idiom as `document.rs`. + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .set_dpns_name_price(&owner_id, &name_str, price_credits, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +/// Delist `name` — remove its `$price` while keeping ownership. +/// +/// Goes through `IdentityWallet::delist_dpns_name`, which broadcasts a +/// transfer to the owner's OWN identity: consensus strips `$price` on +/// transfer, and DPNS has no dedicated remove-price transition. The Rust +/// side verifies the confirmed document actually carries no `$price` +/// before recording the delist locally, so a consensus-semantics change +/// fails loudly rather than persisting a delist that didn't happen. +/// +/// `out_state` receives the confirmed state — release it with +/// [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_delist_name( + wallet_handle: Handle, + owner_identity_id: *const u8, + name: *const c_char, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let owner_id = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity.delist_dpns_name(&owner_id, &name_str, signer).await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +/// Transfer `name` to `recipient_id` without payment (a gift or +/// off-market handover). Consensus strips any `$price` on transfer, so +/// this also delists. +/// +/// Goes through `IdentityWallet::transfer_dpns_name`, which reconciles +/// both sides locally when they belong to this wallet. Use +/// [`platform_wallet_dpns_delist_name`] for a transfer to self — this +/// entry point rejects `recipient_id == owner_identity_id` with an +/// invalid-parameter error. +/// +/// `out_state` receives the confirmed state — release it with +/// [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_transfer_name( + wallet_handle: Handle, + owner_identity_id: *const u8, + name: *const c_char, + recipient_id: *const u8, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let owner_id = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let recipient = unwrap_result_or_return!(unsafe { read_identifier(recipient_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .transfer_dpns_name(&owner_id, &name_str, &recipient, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +/// Purchase `name` for `purchaser_identity_id` at exactly +/// `expected_price_credits` — the price the user confirmed. +/// +/// Goes through `IdentityWallet::purchase_dpns_name`, whose pre-flight +/// is fully typed: name resolution (contested-aware), a self-purchase +/// guard, `ErrorDocumentNotForSale` (37), `ErrorDocumentPriceChanged` +/// (38) when the listing no longer matches, and +/// `ErrorInsufficientIdentityCredits` (39) when the buyer's balance +/// can't cover the price plus the fee reserve. +/// +/// The broadcast transition carries `expected_price_credits`, NEVER a +/// re-read price, so a listing change between pre-flight and broadcast +/// is rejected by consensus and surfaces as the same typed code 38 — the +/// purchase does not execute at an unconfirmed price. +/// +/// `out_state` receives the confirmed state (now owned by the +/// purchaser) — release it with [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_purchase_name( + wallet_handle: Handle, + purchaser_identity_id: *const u8, + name: *const c_char, + expected_price_credits: u64, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let purchaser_id = unwrap_result_or_return!(unsafe { read_identifier(purchaser_identity_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .purchase_dpns_name(&purchaser_id, &name_str, expected_price_credits, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// On-demand sync +// --------------------------------------------------------------------------- + +/// Run one marketplace sync pass on THIS wallet and report its delta. +/// +/// Refreshes owned-name rows (price / sale state), adds newly observed +/// names to the identity label lists, detects names that LEFT an +/// identity (sold or transferred away), and refreshes the balances of +/// identities that sold a name. All four out-params are optional — pass +/// `null` to ignore any of them: +/// +/// * `out_names_tracked`: owned-name rows written this pass. +/// * `out_names_added`: labels newly observed on a wallet identity. +/// * `out_names_departed`: names that left a wallet identity. +/// * `out_prices_changed`: listed-price changes since the last pass. +/// +/// This is the per-wallet, on-demand entry point (pull-to-refresh). The +/// recurring cross-wallet sweep is the manager-level coordinator in +/// [`crate::dpns_sync`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_sync( + wallet_handle: Handle, + out_names_tracked: *mut u32, + out_names_added: *mut u32, + out_names_departed: *mut u32, + out_prices_changed: *mut u32, +) -> PlatformWalletFFIResult { + // Optional out-params: define every non-null slot before the fallible + // work so an error return leaves well-defined zeros, not garbage. + unsafe { + for slot in [ + out_names_tracked, + out_names_added, + out_names_departed, + out_prices_changed, + ] { + if !slot.is_null() { + *slot = 0; + } + } + } + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.sync_dpns_marketplace().await }) + }); + let result = unwrap_option_or_return!(option); + let summary = unwrap_result_or_return!(result); + + unsafe { + if !out_names_tracked.is_null() { + *out_names_tracked = summary.names_tracked; + } + if !out_names_added.is_null() { + *out_names_added = summary.names_added.len() as u32; + } + if !out_names_departed.is_null() { + *out_names_departed = summary.names_departed.len() as u32; + } + if !out_prices_changed.is_null() { + *out_prices_changed = summary.prices_changed.len() as u32; + } + } + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Destructors +// --------------------------------------------------------------------------- + +/// Release a SINGLE [`DpnsMarketplaceNameFFI`] returned through an +/// `out_state` / `out_result` pointer — its two label strings, then the +/// value itself. No-op on `null`. +/// +/// Not interchangeable with [`dpns_marketplace_names_free`]: a single +/// value is a `Box`, an array is a `Box<[T]>`. +/// +/// # Safety +/// `name` must be null or a pointer this module returned through a +/// single-value out-param, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_marketplace_name_free(name: *mut DpnsMarketplaceNameFFI) { + if name.is_null() { + return; + } + let mut boxed = unsafe { Box::from_raw(name) }; + unsafe { + free_owned_c_string(&mut boxed.label); + free_owned_c_string(&mut boxed.normalized_label); + } +} + +/// Release an array of [`DpnsMarketplaceNameFFI`] — every row's two +/// label strings, then the array. No-op on `null` / `count == 0` (the +/// empty-result shape). +/// +/// # Safety +/// `names` must be null or an array this module returned with exactly +/// `count` elements, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_marketplace_names_free( + names: *mut DpnsMarketplaceNameFFI, + count: usize, +) { + if names.is_null() || count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(names, count) }; + for row in slice.iter_mut() { + unsafe { + free_owned_c_string(&mut row.label); + free_owned_c_string(&mut row.normalized_label); + } + } + let _ = unsafe { Box::from_raw(slice as *mut [DpnsMarketplaceNameFFI]) }; +} + +/// Release an array of [`DpnsNameStateRowFFI`] — every row's two label +/// strings, then the array. No-op on `null` / `count == 0`. +/// +/// # Safety +/// `rows` must be null or an array this module returned with exactly +/// `count` elements, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_name_state_rows_free(rows: *mut DpnsNameStateRowFFI, count: usize) { + if rows.is_null() || count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(rows, count) }; + for row in slice.iter_mut() { + unsafe { + free_owned_c_string(&mut row.label); + free_owned_c_string(&mut row.normalized_label); + } + } + let _ = unsafe { Box::from_raw(slice as *mut [DpnsNameStateRowFFI]) }; +} + +/// Release an array of [`DpnsNameHistoryEventFFI`]. The rows are all-POD +/// (no owned strings), so this only reclaims the array allocation. +/// No-op on `null` / `count == 0`. +/// +/// # Safety +/// `events` must be null or an array this module returned with exactly +/// `count` elements, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_name_history_events_free( + events: *mut DpnsNameHistoryEventFFI, + count: usize, +) { + if events.is_null() || count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(events, count) }; + let _ = unsafe { Box::from_raw(slice as *mut [DpnsNameHistoryEventFFI]) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn domain_state(price: Option, records_identity: Option) -> DpnsDomainState { + DpnsDomainState { + document_id: Identifier::from([1u8; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + owner_id: Identifier::from([2u8; 32]), + records_identity_id: records_identity, + price, + created_at_ms: Some(10), + updated_at_ms: None, + transferred_at_ms: Some(30), + } + } + + fn state_entry(status: DpnsNameSaleStatus, price: Option) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([1u8; 32]), + wallet_identity_id: Identifier::from([2u8; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + price, + status, + created_at_ms: Some(10), + updated_at_ms: Some(20), + transferred_at_ms: None, + last_synced_at_ms: 99, + } + } + + /// Absent optionals must cross as `has_* == false`, never as a + /// fabricated value: "not for sale" and "listed at 0 credits" are + /// different facts, and so are "no `$updatedAt`" and "updated at + /// epoch". + #[test] + fn marketplace_name_absent_optionals_are_flagged_not_fabricated() { + let mut ffi = DpnsMarketplaceNameFFI::from_state(&domain_state(None, None)); + assert!(!ffi.has_price); + assert_eq!(ffi.price, 0); + assert!(!ffi.has_records_identity); + assert_eq!(ffi.records_identity_id, [0u8; 32]); + assert_eq!(ffi.updated_at_ms, 0); + assert_eq!(ffi.created_at_ms, 10); + assert_eq!(ffi.transferred_at_ms, 30); + unsafe { + free_owned_c_string(&mut ffi.label); + free_owned_c_string(&mut ffi.normalized_label); + } + } + + #[test] + fn marketplace_name_round_trips_present_fields() { + let records = Identifier::from([3u8; 32]); + let ffi = DpnsMarketplaceNameFFI::from_state(&domain_state(Some(5_000), Some(records))); + assert_eq!(ffi.document_id, [1u8; 32]); + assert_eq!(ffi.owner_id, [2u8; 32]); + assert!(ffi.has_records_identity); + assert_eq!(ffi.records_identity_id, [3u8; 32]); + assert!(ffi.has_price); + assert_eq!(ffi.price, 5_000); + let label = unsafe { CStr::from_ptr(ffi.label) }.to_string_lossy().into_owned(); + let normalized = unsafe { CStr::from_ptr(ffi.normalized_label) } + .to_string_lossy() + .into_owned(); + assert_eq!(label, "Alice"); + assert_eq!(normalized, "a11ce"); + // Free through the public single-value destructor, which is what + // the host calls. + unsafe { dpns_marketplace_name_free(Box::into_raw(Box::new(ffi))) }; + } + + /// The status discriminants are the ABI contract with the host's + /// `DpnsNameSaleStatus` mirror; pin all three plus their + /// counterparty flags. + #[test] + fn name_state_row_pins_status_discriminants() { + let buyer = Identifier::from([7u8; 32]); + let cases = [ + (DpnsNameSaleStatus::Owned, 0u8, false, [0u8; 32]), + (DpnsNameSaleStatus::Sold { to: buyer }, 1u8, true, [7u8; 32]), + ( + DpnsNameSaleStatus::Transferred { to: buyer }, + 2u8, + true, + [7u8; 32], + ), + ]; + for (status, expected_status, expected_has_cp, expected_cp) in cases { + let row = DpnsNameStateRowFFI::from_entry(&state_entry(status, Some(1))); + assert_eq!(row.status, expected_status); + assert_eq!(row.has_counterparty, expected_has_cp); + assert_eq!(row.counterparty_id, expected_cp); + assert_eq!(row.last_synced_at_ms, 99); + free_rows(vec![row]); + } + } + + #[test] + fn name_state_row_unlisted_price_is_flagged() { + let row = DpnsNameStateRowFFI::from_entry(&state_entry(DpnsNameSaleStatus::Owned, None)); + assert!(!row.has_price); + assert_eq!(row.price, 0); + assert_eq!(row.transferred_at_ms, 0); + free_rows(vec![row]); + } + + /// Publish `rows` exactly as an entry point would, then release them + /// through the public destructor — so the tests exercise the real + /// allocation shape (`Box<[T]>`) rather than a hand-rolled one. + fn free_rows(rows: Vec) { + let mut out: *mut DpnsNameStateRowFFI = ptr::null_mut(); + let mut count: usize = 0; + unsafe { + publish_array(rows, &mut out, &mut count); + dpns_name_state_rows_free(out, count); + } + } + + /// The event-kind discriminants and which optional payloads each kind + /// carries are both ABI contracts — a host reading `price` on a + /// registration event, or mistaking a purchase for a transfer, shows + /// the user a wrong trade history. + #[test] + fn history_event_kinds_and_payloads_are_pinned() { + let seller = Identifier::from([4u8; 32]); + let buyer = Identifier::from([5u8; 32]); + + let registered = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Registered, + at_ms: 1, + block_height: None, + }); + assert_eq!(registered.kind, 0); + assert!(!registered.has_price); + assert!(!registered.has_from); + assert!(!registered.has_to); + assert!(!registered.has_block_height); + assert_eq!(registered.block_height, 0); + + let priced = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::PriceSet { price: 42 }, + at_ms: 2, + block_height: Some(1_000), + }); + assert_eq!(priced.kind, 1); + assert!(priced.has_price); + assert_eq!(priced.price, 42); + assert!(!priced.has_from); + assert!(priced.has_block_height); + assert_eq!(priced.block_height, 1_000); + + let purchased = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Purchased { + price: 7, + seller, + buyer, + }, + at_ms: 3, + block_height: None, + }); + assert_eq!(purchased.kind, 2); + assert!(purchased.has_price); + assert_eq!(purchased.price, 7); + // Purchase: from = seller, to = buyer. + assert_eq!(purchased.from_id, [4u8; 32]); + assert_eq!(purchased.to_id, [5u8; 32]); + + let transferred = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Transferred { + from: seller, + to: buyer, + }, + at_ms: 4, + block_height: None, + }); + assert_eq!(transferred.kind, 3); + assert!(!transferred.has_price); + assert_eq!(transferred.from_id, [4u8; 32]); + assert_eq!(transferred.to_id, [5u8; 32]); + } + + /// Every destructor must tolerate the empty-result shape (`null` + + /// `0`) and a bare `null`, since that is exactly what a + /// no-results-but-successful call publishes. + #[test] + fn destructors_are_null_and_empty_tolerant() { + unsafe { + dpns_marketplace_name_free(ptr::null_mut()); + dpns_marketplace_names_free(ptr::null_mut(), 0); + dpns_marketplace_names_free(ptr::null_mut(), 3); + dpns_name_state_rows_free(ptr::null_mut(), 0); + dpns_name_history_events_free(ptr::null_mut(), 0); + } + } + + /// `publish_array` on an empty Vec must write the documented + /// `null` + `0` pair rather than a dangling one-past-the-end pointer, + /// and the paired free must accept it. + #[test] + fn publish_array_writes_the_empty_shape() { + // Seed the out-slots with garbage a caller could mistake for a + // real result, so the assertions below prove they were OVERWRITTEN + // rather than merely left alone. + let mut out: *mut DpnsMarketplaceNameFFI = std::ptr::dangling_mut(); + let mut count: usize = 7; + unsafe { publish_array(Vec::new(), &mut out, &mut count) }; + assert!(out.is_null()); + assert_eq!(count, 0); + unsafe { dpns_marketplace_names_free(out, count) }; + } + + /// A populated array round-trips through `publish_array` and its + /// destructor without leaking the per-row label strings (verified + /// under the test harness's allocator; a double free would abort). + #[test] + fn publish_array_round_trips_and_frees_rows() { + let states = [ + domain_state(Some(1), None), + domain_state(None, Some(Identifier::from([6u8; 32]))), + ]; + let rows: Vec = states + .iter() + .map(DpnsMarketplaceNameFFI::from_state) + .collect(); + let mut out: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let mut count: usize = 0; + unsafe { publish_array(rows, &mut out, &mut count) }; + assert!(!out.is_null()); + assert_eq!(count, 2); + let first_label = unsafe { CStr::from_ptr((*out).label) } + .to_string_lossy() + .into_owned(); + assert_eq!(first_label, "Alice"); + unsafe { dpns_marketplace_names_free(out, count) }; + } + + /// Unknown handles must surface as `NotFound` through + /// `unwrap_option_or_return!` rather than dereferencing a stale slot, + /// and required out-pointers must be rejected first with + /// `ErrorNullPointer`. Covers the pointer-discipline half of every + /// entry point without needing a live wallet. + #[test] + fn unknown_handle_and_null_out_pointers_are_rejected() { + let bogus: Handle = 0xDEAD_BEEF; + + let mut names: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let mut count: usize = 0; + let prefix = CString::new("a").unwrap(); + let r = unsafe { + platform_wallet_dpns_marketplace_search( + bogus, + prefix.as_ptr(), + 0, + ptr::null(), + &mut names, + &mut count, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!(names.is_null()); + assert_eq!(count, 0); + + let mut rows: *mut DpnsNameStateRowFFI = ptr::null_mut(); + let mut rows_count: usize = 0; + let r = unsafe { + platform_wallet_dpns_marketplace_my_names( + bogus, + ptr::null(), + &mut rows, + &mut rows_count, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + // All four sync out-params are optional — a null-only call must + // still reach the handle lookup. + let r = unsafe { + platform_wallet_dpns_marketplace_sync( + bogus, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + // Required out-pointer missing: rejected before the handle lookup. + let c = CString::new("alice").unwrap(); + let r = unsafe { + platform_wallet_dpns_marketplace_name_state(bogus, c.as_ptr(), ptr::null_mut()) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + // Missing signer handle: rejected before the handle lookup too. + let mut state: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let r = unsafe { + platform_wallet_dpns_set_name_price( + bogus, + [0u8; 32].as_ptr(), + c.as_ptr(), + 1, + ptr::null_mut(), + &mut state, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!(state.is_null()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs b/packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs new file mode 100644 index 00000000000..f03cc60b835 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs @@ -0,0 +1,272 @@ +//! FFI types for forwarding +//! [`DpnsNameStateChangeSet`](platform_wallet::changeset::DpnsNameStateChangeSet) +//! — the DPNS username-marketplace rows — out of +//! [`FFIPersister`](crate::persistence::FFIPersister) to the host. +//! +//! Shaped like [`crate::invitation_persistence`], with one difference: +//! [`DpnsNameStateEntry`] carries three owned strings (the display label, +//! its homograph-normalized form, and the normalized parent domain), so +//! each row owns `CString` allocations that MUST be released with +//! [`free_dpns_name_state_entries`] after the callback returns — exactly +//! the allocate/free discipline `IdentityEntryFFI`'s DPNS label arrays +//! use in [`crate::identity_persistence`]. +//! +//! The strings are Rust-owned and valid only for the callback window; +//! the host must copy anything it keeps before returning. + +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; + +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateEntry}; + +/// C mirror of one [`DpnsNameStateEntry`]: a DPNS `domain` document +/// tracked for a wallet identity, with its sale state. +/// +/// The three `*const c_char` fields are NUL-terminated UTF-8 owned by +/// this struct for the duration of the persistence callback. Optional +/// values travel as a `has_*` flag plus the value — never as a sentinel, +/// so "not for sale" stays distinguishable from "listed at 0 credits" +/// and "no `$updatedAt`" from "updated at the epoch". +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct DpnsNameStateFFI { + /// The DPNS `domain` document id — this row's key, stable across + /// transfers and purchases. + pub document_id: [u8; 32], + /// The wallet identity this row is tracked for. For `Owned` rows the + /// document's `$ownerId`; for `Sold`/`Transferred` rows the previous + /// owner (ours). + pub wallet_identity_id: [u8; 32], + /// Whether `counterparty_id` is populated — true exactly when + /// `status != 0`. + pub has_counterparty: bool, + /// The buyer (`status == 1`) or recipient (`status == 2`). Ignore + /// unless `has_counterparty`. + pub counterparty_id: [u8; 32], + /// Display label, e.g. "Alice". + pub label: *const c_char, + /// Homograph-normalized label, e.g. "a11ce". + pub normalized_label: *const c_char, + /// Normalized parent domain — "dash" today. Carried (rather than + /// defaulted host-side) because it is part of the host's row + /// uniqueness key alongside the normalized label. + pub normalized_parent_domain_name: *const c_char, + /// Whether `price` is populated. `false` = not listed for sale. + pub has_price: bool, + /// Listed sale price in credits (`$price`). Ignore unless `has_price`. + pub price: u64, + /// Ownership status relative to `wallet_identity_id`: + /// `0` = owned, `1` = sold, `2` = transferred. + pub status: u8, + /// Document `$createdAt` in ms. `0` = unknown. + pub created_at_ms: u64, + /// Document `$updatedAt` in ms. `0` = unknown. + pub updated_at_ms: u64, + /// Document `$transferredAt` in ms. `0` = unknown. + pub transferred_at_ms: u64, + /// Wall-clock ms of the sync pass / confirmed transition that wrote + /// this row. + pub last_synced_at_ms: u64, +} + +// Pin the ABI size so a future field reorder/add that changes the layout +// is a compile error rather than a silent desync (the layout-assert +// convention every other `*EntryFFI` follows). +// [u8;32]@0, [u8;32]@32, bool@64, [u8;32]@65..97, 3 ptrs@104..128, +// bool@128, u64@136, u8@144, 4 u64@152..184 → align 8 → size 184. +const _: [u8; 184] = [0u8; std::mem::size_of::()]; + +/// Discriminant mapping for [`DpnsNameSaleStatus`], plus the +/// counterparty it carries. Wildcard-free so adding a variant is a +/// compile error rather than a silent mis-map. Pinned by a test. +fn status_and_counterparty(status: &DpnsNameSaleStatus) -> (u8, bool, [u8; 32]) { + match status { + DpnsNameSaleStatus::Owned => (0, false, [0u8; 32]), + DpnsNameSaleStatus::Sold { to } => (1, true, to.to_buffer()), + DpnsNameSaleStatus::Transferred { to } => (2, true, to.to_buffer()), + } +} + +/// Heap-allocate `s` as an owned C string, or `null` if it contains an +/// interior NUL (unreachable for DPNS-validated labels, but a null is +/// far better than a panic across the boundary). Released by +/// [`free_dpns_name_state_entries`]. +fn owned_c_string(s: &str) -> *const c_char { + match CString::new(s) { + Ok(c) => c.into_raw() as *const c_char, + Err(_) => ptr::null(), + } +} + +/// Build the flat FFI rows from the changeset entries. +/// +/// Every returned row owns three `CString` allocations — the caller MUST +/// pass the Vec to [`free_dpns_name_state_entries`] once the persistence +/// callback has returned. +pub fn build_dpns_name_state_entries(entries: &[&DpnsNameStateEntry]) -> Vec { + entries + .iter() + .map(|entry| { + let (status, has_counterparty, counterparty_id) = + status_and_counterparty(&entry.status); + let (has_price, price) = match entry.price { + Some(p) => (true, p), + None => (false, 0), + }; + DpnsNameStateFFI { + document_id: entry.document_id.to_buffer(), + wallet_identity_id: entry.wallet_identity_id.to_buffer(), + has_counterparty, + counterparty_id, + label: owned_c_string(&entry.label), + normalized_label: owned_c_string(&entry.normalized_label), + normalized_parent_domain_name: owned_c_string( + &entry.normalized_parent_domain_name, + ), + has_price, + price, + status, + created_at_ms: entry.created_at_ms.unwrap_or(0), + updated_at_ms: entry.updated_at_ms.unwrap_or(0), + transferred_at_ms: entry.transferred_at_ms.unwrap_or(0), + last_synced_at_ms: entry.last_synced_at_ms, + } + }) + .collect() +} + +/// Release the three owned C strings on every row and null the slots. +/// Idempotent — a second call is a no-op. +/// +/// # Safety +/// +/// Every row must have been produced by [`build_dpns_name_state_entries`] +/// and not previously freed; the pointers must reference allocations +/// owned by these rows. +pub unsafe fn free_dpns_name_state_entries(entries: &mut [DpnsNameStateFFI]) { + for entry in entries.iter_mut() { + unsafe { + free_owned_c_string(&mut entry.label); + free_owned_c_string(&mut entry.normalized_label); + free_owned_c_string(&mut entry.normalized_parent_domain_name); + } + } +} + +/// Release one C string produced by [`owned_c_string`] and null the slot +/// in place, so repeated frees no-op. +/// +/// # Safety +/// The pointer must be null or a `CString::into_raw` allocation. +unsafe fn free_owned_c_string(slot: &mut *const c_char) { + if !slot.is_null() { + let _ = unsafe { CString::from_raw(*slot as *mut c_char) }; + *slot = ptr::null(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use std::ffi::CStr; + + fn entry(status: DpnsNameSaleStatus, price: Option) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([1u8; 32]), + wallet_identity_id: Identifier::from([2u8; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + price, + status, + created_at_ms: Some(10), + updated_at_ms: None, + transferred_at_ms: Some(30), + last_synced_at_ms: 99, + } + } + + /// The status discriminants are the ABI contract with the host's + /// mirror; pin all three plus the counterparty they carry. + #[test] + fn status_discriminants_are_pinned() { + let to = Identifier::from([7u8; 32]); + assert_eq!( + status_and_counterparty(&DpnsNameSaleStatus::Owned), + (0, false, [0u8; 32]) + ); + assert_eq!( + status_and_counterparty(&DpnsNameSaleStatus::Sold { to }), + (1, true, [7u8; 32]) + ); + assert_eq!( + status_and_counterparty(&DpnsNameSaleStatus::Transferred { to }), + (2, true, [7u8; 32]) + ); + } + + #[test] + fn build_entries_round_trips_every_field() { + let owned = entry(DpnsNameSaleStatus::Owned, Some(5_000)); + let sold = entry( + DpnsNameSaleStatus::Sold { + to: Identifier::from([7u8; 32]), + }, + None, + ); + let refs = [&owned, &sold]; + let mut ffi = build_dpns_name_state_entries(&refs); + assert_eq!(ffi.len(), 2); + + assert_eq!(ffi[0].document_id, [1u8; 32]); + assert_eq!(ffi[0].wallet_identity_id, [2u8; 32]); + assert_eq!(ffi[0].status, 0); + assert!(!ffi[0].has_counterparty); + assert!(ffi[0].has_price); + assert_eq!(ffi[0].price, 5_000); + assert_eq!(ffi[0].created_at_ms, 10); + // Absent `$updatedAt` must arrive as 0-and-unknown, not fabricated. + assert_eq!(ffi[0].updated_at_ms, 0); + assert_eq!(ffi[0].transferred_at_ms, 30); + assert_eq!(ffi[0].last_synced_at_ms, 99); + let label = unsafe { CStr::from_ptr(ffi[0].label) } + .to_string_lossy() + .into_owned(); + let normalized = unsafe { CStr::from_ptr(ffi[0].normalized_label) } + .to_string_lossy() + .into_owned(); + let parent = unsafe { CStr::from_ptr(ffi[0].normalized_parent_domain_name) } + .to_string_lossy() + .into_owned(); + assert_eq!(label, "Alice"); + assert_eq!(normalized, "a11ce"); + assert_eq!(parent, "dash"); + + assert_eq!(ffi[1].status, 1); + assert!(ffi[1].has_counterparty); + assert_eq!(ffi[1].counterparty_id, [7u8; 32]); + // Not listed: flagged, never rendered as a 0-credit listing. + assert!(!ffi[1].has_price); + assert_eq!(ffi[1].price, 0); + + unsafe { free_dpns_name_state_entries(&mut ffi) }; + assert!(ffi[0].label.is_null()); + assert!(ffi[0].normalized_label.is_null()); + assert!(ffi[0].normalized_parent_domain_name.is_null()); + // Idempotent — the dispatcher frees on every path, including the + // one where the callback returned an error. + unsafe { free_dpns_name_state_entries(&mut ffi) }; + } + + /// An empty changeset produces an empty Vec, and freeing it is a + /// no-op — the shape `store()` hits when a round carries only + /// tombstones. + #[test] + fn empty_entries_build_and_free_cleanly() { + let mut ffi = build_dpns_name_state_entries(&[]); + assert!(ffi.is_empty()); + unsafe { free_dpns_name_state_entries(&mut ffi) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dpns_sync.rs b/packages/rs-platform-wallet-ffi/src/dpns_sync.rs new file mode 100644 index 00000000000..1c2dd31ce8a --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dpns_sync.rs @@ -0,0 +1,245 @@ +//! FFI bindings for `PlatformWalletManager`'s recurring DPNS +//! username-marketplace sync coordinator. +//! +//! Sibling of [`crate::dashpay_sync`] and shaped identically: lifecycle +//! controls (`start` / `stop` / `is_running` / `is_syncing` / +//! `last_sync_unix_seconds` / `set_interval` / `sync_now`). The sweep is +//! **wallet-driven, not registry-driven** (see +//! [`DpnsSyncManager`](platform_wallet::manager::dpns_sync::DpnsSyncManager)), +//! so there is no per-identity registry surface here — every registered +//! wallet is swept on every pass. It is a separate coordinator from the +//! DashPay one because marketplace state changes are rare: this loop +//! defaults to 60s against DashPay's 15s. +//! +//! `sync_now` surfaces the per-pass success / error counts and +//! completion timestamp through out-params; all three are optional — +//! pass null to ignore any of them. For a single wallet's delta (names +//! tracked / added / departed / re-priced) use the per-wallet +//! [`platform_wallet_dpns_marketplace_sync`](crate::dpns_marketplace::platform_wallet_dpns_marketplace_sync) +//! instead. +//! +//! Not auto-started. The host lifecycle calls +//! [`platform_wallet_manager_dpns_sync_start`] once the wallets are +//! registered and the SDK is connected; the on-demand `sync_now` entry +//! point stays available for pull-to-refresh. + +use std::time::Duration; + +use crate::error::*; +use crate::handle::*; +use crate::runtime::{block_on_worker, runtime}; +use crate::{check_ptr, unwrap_option_or_return}; + +/// Start the recurring DPNS marketplace sync loop in the background. +/// Idempotent — calling while already running is a no-op. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_start( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let _entered = runtime().enter(); + manager.dpns_sync_arc().start(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Stop the recurring DPNS marketplace sync loop if it is running. +/// +/// Cancel-only: a pass already inside `sync_now` keeps running to +/// completion. Manager shutdown uses the Rust-side `quiesce` barrier; +/// the host does not need to. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_stop( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.dpns_sync().stop(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Whether the recurring DPNS marketplace sync background loop is +/// running. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_is_running( + handle: Handle, + out_running: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_running); + // Define the out-slot before the stale-handle early return below can + // fire, so the caller never reads uninitialized stack contents. + *out_running = false; + + let option = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| manager.dpns_sync().is_running()); + let running = unwrap_option_or_return!(option); + *out_running = running; + PlatformWalletFFIResult::ok() +} + +/// Whether a DPNS marketplace sync pass is currently in flight. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_is_syncing( + handle: Handle, + out_syncing: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_syncing); + *out_syncing = false; + + let option = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| manager.dpns_sync().is_syncing()); + let syncing = unwrap_option_or_return!(option); + *out_syncing = syncing; + PlatformWalletFFIResult::ok() +} + +/// Unix seconds of the last completed DPNS marketplace sync pass, or 0 +/// if no pass has ever completed. +/// +/// The watermark is global (one last-sync per manager, not per-wallet), +/// matching the wallet-driven sweep. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_last_sync_unix_seconds( + handle: Handle, + out_last_sync_unix: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_last_sync_unix); + *out_last_sync_unix = 0; + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.dpns_sync().last_sync_unix_seconds()); + let value = unwrap_option_or_return!(option); + *out_last_sync_unix = value.unwrap_or(0); + PlatformWalletFFIResult::ok() +} + +/// Set the background DPNS marketplace sync interval in seconds. +/// +/// Clamped to a minimum of 1s on the Rust side; the running loop picks +/// up the new interval on its next sleep. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_set_interval( + handle: Handle, + interval_seconds: u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager + .dpns_sync() + .set_interval(Duration::from_secs(interval_seconds)); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Run one DPNS marketplace sync pass across every registered wallet. +/// +/// Synchronous from the FFI caller's point of view — blocks the calling +/// thread until the pass completes. If a pass is already in flight (e.g. +/// fired by the background loop), the underlying manager skips and +/// returns an empty summary immediately; this function then reports +/// `*out_success_count == 0`, `*out_error_count == 0`, and +/// `*out_sync_unix_seconds == 0` (the "no pass ran" sentinel). Check +/// `is_syncing` if the caller needs to distinguish "skipped" from +/// "swept zero wallets". +/// +/// All three out-params are optional — pass null to ignore any of them: +/// * `out_success_count`: wallets whose marketplace sync succeeded. +/// * `out_error_count`: wallets whose marketplace sync failed (logged +/// Rust-side, non-fatal to the rest of the pass). +/// * `out_sync_unix_seconds`: Unix seconds the pass completed, or `0` +/// if no pass ran. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_sync_now( + handle: Handle, + out_success_count: *mut usize, + out_error_count: *mut usize, + out_sync_unix_seconds: *mut u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let mgr = manager.dpns_sync_arc(); + // `block_on_worker`, NOT `runtime().block_on`: the pass verifies + // GroveDB document-query proofs whose recursion blows the ~512 KB + // stack of the iOS calling thread. The worker dispatch moves the + // compute onto the runtime's 8 MB-stack threads (see runtime.rs). + block_on_worker(async move { mgr.sync_now().await }) + }); + let summary = unwrap_option_or_return!(option); + + if !out_success_count.is_null() { + *out_success_count = summary.success_count(); + } + if !out_error_count.is_null() { + *out_error_count = summary.error_count(); + } + if !out_sync_unix_seconds.is_null() { + *out_sync_unix_seconds = summary.sync_unix_seconds; + } + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every DPNS-sync entry point must reject an unknown `Handle` with + /// `NotFound` rather than dereferencing a stale slot — the + /// `unwrap_option_or_return!` contract every other coordinator's FFI + /// upholds. Pins the stale-handle path for all seven calls. + #[test] + fn unknown_handle_returns_not_found() { + let bogus: Handle = 0xDEAD_BEEF; + + let r = unsafe { platform_wallet_manager_dpns_sync_start(bogus) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let r = unsafe { platform_wallet_manager_dpns_sync_stop(bogus) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut running = true; + let r = unsafe { platform_wallet_manager_dpns_sync_is_running(bogus, &mut running) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!(!running); + + let mut syncing = true; + let r = unsafe { platform_wallet_manager_dpns_sync_is_syncing(bogus, &mut syncing) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!(!syncing); + + let mut last = 123u64; + let r = + unsafe { platform_wallet_manager_dpns_sync_last_sync_unix_seconds(bogus, &mut last) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(last, 0); + + let r = unsafe { platform_wallet_manager_dpns_sync_set_interval(bogus, 30) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut ok = 7usize; + let mut err = 7usize; + let mut ts = 7u64; + let r = + unsafe { platform_wallet_manager_dpns_sync_sync_now(bogus, &mut ok, &mut err, &mut ts) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + } + + /// Null out-pointers on the reader entry points must be rejected with + /// `ErrorNullPointer` (the `check_ptr!` contract) before the handle is + /// even looked up. + #[test] + fn null_required_out_pointers_are_rejected() { + let bogus: Handle = 1; + + let r = unsafe { platform_wallet_manager_dpns_sync_is_running(bogus, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let r = unsafe { platform_wallet_manager_dpns_sync_is_syncing(bogus, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let r = unsafe { + platform_wallet_manager_dpns_sync_last_sync_unix_seconds(bogus, std::ptr::null_mut()) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 99477d23677..f1f4c9775dc 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1,3 +1,4 @@ +use dpp::platform_value::string_encoding::Encoding; use platform_wallet::PlatformWalletError; use std::ffi::CString; use std::os::raw::c_char; @@ -256,6 +257,18 @@ pub enum PlatformWalletFFIResultCode { // This trio previously sat at 26-28, then 27/28/30. It moved to 34-36 after // #4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev ABI; the // contiguous block above every current claim ends the renumbering churn. + // + // Claimed after the trio, same rule (fresh block above every claim): + // + // 37 ErrorDocumentNotForSale DPNS username marketplace + // 38 ErrorDocumentPriceChanged DPNS username marketplace + // 39 ErrorInsufficientIdentityCredits DPNS username marketplace + // 40 ErrorContestedNameNotTradable DPNS username marketplace + // + // 38/39/40 carry a STABLE JSON detail object in the result `message` + // instead of the typed `Display` rendering — see each variant's doc for + // the exact object. `PlatformWalletFFIResult` is ABI-frozen (code + + // message only), so structured values ride the message or not at all. /// Maps `SignedPaymentError::StaleReservationToken` from the deferred /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound @@ -288,6 +301,64 @@ pub enum PlatformWalletFFIResultCode { /// ErrorReservationWalletMismatch = 36, + // ----------------------------------------------------------------- + // DPNS username-marketplace trade rejections (37-40). + // + // A fresh contiguous block ABOVE every current claim, for the same + // reason the 34-36 trio moved there: 28 and 30 are nominally free but + // reusing a vacated slot re-opens the renumbering churn the registry + // note above exists to end. + // ----------------------------------------------------------------- + /// Maps `PlatformWalletError::DocumentNotForSale`. The document + /// carries no `$price`, so it cannot be purchased (and a DPNS delist + /// has nothing to clear). Raised by the wallet's pre-flight read and + /// by the downcast of the consensus `DocumentNotForSaleError` (DPP + /// code 40108). The transition did NOT execute. + /// + /// Message: the typed `Display` rendering (no structured detail — + /// the only value is the document id, which the caller already has). + ErrorDocumentNotForSale = 37, + + /// Maps `PlatformWalletError::DocumentPriceChanged`. The listing no + /// longer matches the price the user confirmed — either the wallet's + /// pre-flight read disagreed, or consensus rejected the broadcast + /// with `DocumentIncorrectPurchasePriceError` (DPP code 40109) + /// because the listing changed between read and broadcast. The + /// purchase did NOT execute in either case; re-confirm at the new + /// price and retry. + /// + /// Message: a STABLE JSON detail object so hosts recover the typed + /// values without parsing prose — + /// `{"documentId":"","expected":,"actual":}` + /// (credits). Swift mirror: `PlatformWalletError.priceChanged`. + ErrorDocumentPriceChanged = 38, + + /// Maps `PlatformWalletError::InsufficientIdentityCredits`. The + /// identity's credit balance cannot cover the operation — the + /// wallet's purchase pre-flight (price + fee reserve against the + /// local balance snapshot) or the downcast of the consensus + /// `IdentityInsufficientBalanceError`. Nothing executed; top the + /// identity up and retry. + /// + /// Message: a STABLE JSON detail object — + /// `{"identityId":"","required":,"available":}` + /// (credits). Swift mirror: + /// `PlatformWalletError.insufficientIdentityCredits`. + ErrorInsufficientIdentityCredits = 39, + + /// Maps `PlatformWalletError::ContestedNameNotTradable`. The DPNS + /// name is inside an active contested-name vote, so its domain + /// document is not in the documents tree and no trade transition can + /// reference it. Without this typed code the network's bare + /// `DocumentNotFoundError` (40101) would read as "no such name". + /// Retry after the contest resolves. + /// + /// Message: a STABLE JSON detail object — + /// `{"label":"","endsAtMs":}`, where `endsAtMs == 0` + /// means the vote's end time was unavailable. Swift mirror: + /// `PlatformWalletError.contestedNameNotTradable`. + ErrorContestedNameNotTradable = 40, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -400,8 +471,69 @@ impl From> for PlatformWalletFFIResult { } } +/// The value-carrying DPNS-marketplace rejections, rendered as +/// `(code, JSON detail)` instead of `(code, Display)`. +/// +/// `PlatformWalletFFIResult` is ABI-frozen at `{ code, message }`, so a +/// host that needs the *values* — not prose naming them — can only get +/// them through the message. These three therefore put a stable JSON +/// object there; the exact shape is documented on each +/// [`PlatformWalletFFIResultCode`] variant and parsed back by the Swift +/// mirror. Returns `None` for every other error, leaving the `Display` +/// rendering in charge. +/// +/// `DocumentNotForSale` (37) is deliberately absent: its only value is +/// the document id the caller supplied, so its `Display` is enough. +fn trade_error_json_detail( + error: &PlatformWalletError, +) -> Option<(PlatformWalletFFIResultCode, String)> { + match error { + PlatformWalletError::DocumentPriceChanged { + document_id, + expected, + actual, + } => Some(( + PlatformWalletFFIResultCode::ErrorDocumentPriceChanged, + serde_json::json!({ + "documentId": document_id.to_string(Encoding::Base58), + "expected": expected, + "actual": actual, + }) + .to_string(), + )), + PlatformWalletError::InsufficientIdentityCredits { + identity_id, + required, + available, + } => Some(( + PlatformWalletFFIResultCode::ErrorInsufficientIdentityCredits, + serde_json::json!({ + "identityId": identity_id.to_string(Encoding::Base58), + "required": required, + "available": available, + }) + .to_string(), + )), + PlatformWalletError::ContestedNameNotTradable { label, ends_at_ms } => Some(( + PlatformWalletFFIResultCode::ErrorContestedNameNotTradable, + serde_json::json!({ + "label": label, + "endsAtMs": ends_at_ms, + }) + .to_string(), + )), + _ => None, + } +} + impl From for PlatformWalletFFIResult { fn from(error: PlatformWalletError) -> Self { + // The three value-carrying marketplace rejections replace the + // Display rendering with a stable JSON detail object; everything + // else keeps Display as the message. + if let Some((code, detail)) = trade_error_json_detail(&error) { + return PlatformWalletFFIResult::err(code, detail); + } // Map the typed wallet error variants explicitly so they // don't flatten to ErrorUnknown at the FFI boundary. The // catch-all ErrorUnknown remains for variants the FFI hasn't @@ -531,6 +663,17 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::MessageSigningKeyUnavailable { .. } => { PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable } + // DPNS marketplace: the one trade rejection whose Display is + // sufficient (the other three are handled by + // `trade_error_json_detail` above and never reach this match). + PlatformWalletError::DocumentNotForSale { .. } => { + PlatformWalletFFIResultCode::ErrorDocumentNotForSale + } + // An exact-label DPNS lookup that came back empty IS the + // "does not exist" case this code has always covered, so it + // rides `NotFound` rather than spending a fifth marketplace + // code hosts would handle identically. + PlatformWalletError::DpnsNameNotFound { .. } => PlatformWalletFFIResultCode::NotFound, // NOTE: `MessageSigningFailed` is deliberately NOT matched, so it // falls to the `ErrorUnknown` catch-all below. Its causes are // internal invariant breaks (a public key that does not own the @@ -1259,6 +1402,155 @@ mod tests { /// marker in it sits mid-string, and matching it there would be the /// substring sniff #4183's review rejected. See the NOTE on the mapping /// arm. + /// Read a result's message back as an owned `String`. Every + /// marketplace assertion below inspects the message, and the raw + /// `CStr::from_ptr` dance is noise at each site. + fn message_of(result: &PlatformWalletFFIResult) -> String { + assert!(!result.message.is_null(), "result carries no message"); + unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + } + + /// The four DPNS-marketplace trade rejections each map to their own + /// dedicated code rather than flattening to `ErrorUnknown`, and the + /// not-found case rides the existing `NotFound`. Hosts branch on these + /// to distinguish "re-confirm the price" from "top up credits" from + /// "wait for the contest". + #[test] + fn dpns_marketplace_errors_map_to_dedicated_codes() { + let document_id = dpp::prelude::Identifier::from([9u8; 32]); + let identity_id = dpp::prelude::Identifier::from([8u8; 32]); + let cases: Vec<(PlatformWalletError, PlatformWalletFFIResultCode)> = vec![ + ( + PlatformWalletError::DocumentNotForSale { document_id }, + PlatformWalletFFIResultCode::ErrorDocumentNotForSale, + ), + ( + PlatformWalletError::DocumentPriceChanged { + document_id, + expected: 1_000, + actual: 2_000, + }, + PlatformWalletFFIResultCode::ErrorDocumentPriceChanged, + ), + ( + PlatformWalletError::InsufficientIdentityCredits { + identity_id, + required: 100_001_000, + available: 7, + }, + PlatformWalletFFIResultCode::ErrorInsufficientIdentityCredits, + ), + ( + PlatformWalletError::ContestedNameNotTradable { + label: "alice".to_string(), + ends_at_ms: 1_800_000_000_000, + }, + PlatformWalletFFIResultCode::ErrorContestedNameNotTradable, + ), + ( + PlatformWalletError::DpnsNameNotFound { + name: "nobody".to_string(), + }, + PlatformWalletFFIResultCode::NotFound, + ), + ]; + for (error, expected_code) in cases { + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + assert_eq!( + result.code, expected_code, + "variant should map to {expected_code:?} (rendered: {rendered})" + ); + } + } + + /// Code 37 keeps the typed `Display` rendering as its message — it + /// carries no value the caller doesn't already have, so it is NOT in + /// the JSON-detail set. + #[test] + fn document_not_for_sale_message_is_the_display_rendering() { + let err = PlatformWalletError::DocumentNotForSale { + document_id: dpp::prelude::Identifier::from([9u8; 32]), + }; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!(message_of(&result), rendered); + } + + /// Codes 38/39/40 put a STABLE JSON detail object in the message so + /// the Swift mirror can rebuild typed cases. Pin the exact keys and + /// values — a rename or a transposed pair silently degrades every host + /// to `.unknown`, which no compiler catches across the ABI. + #[test] + fn price_changed_message_is_the_documented_json_detail() { + let document_id = dpp::prelude::Identifier::from([9u8; 32]); + let result: PlatformWalletFFIResult = PlatformWalletError::DocumentPriceChanged { + document_id, + expected: 1_000, + actual: 2_000, + } + .into(); + let parsed: serde_json::Value = serde_json::from_str(&message_of(&result)) + .expect("code 38 message must parse as JSON"); + assert_eq!(parsed["documentId"], document_id.to_string(Encoding::Base58)); + assert_eq!(parsed["expected"], 1_000u64); + assert_eq!(parsed["actual"], 2_000u64); + } + + #[test] + fn insufficient_credits_message_is_the_documented_json_detail() { + let identity_id = dpp::prelude::Identifier::from([8u8; 32]); + let result: PlatformWalletFFIResult = PlatformWalletError::InsufficientIdentityCredits { + identity_id, + required: 100_001_000, + available: 7, + } + .into(); + let parsed: serde_json::Value = serde_json::from_str(&message_of(&result)) + .expect("code 39 message must parse as JSON"); + assert_eq!(parsed["identityId"], identity_id.to_string(Encoding::Base58)); + assert_eq!(parsed["required"], 100_001_000u64); + assert_eq!(parsed["available"], 7u64); + } + + #[test] + fn contested_name_message_is_the_documented_json_detail() { + let result: PlatformWalletFFIResult = PlatformWalletError::ContestedNameNotTradable { + label: "alice".to_string(), + ends_at_ms: 1_800_000_000_000, + } + .into(); + let parsed: serde_json::Value = serde_json::from_str(&message_of(&result)) + .expect("code 40 message must parse as JSON"); + assert_eq!(parsed["label"], "alice"); + assert_eq!(parsed["endsAtMs"], 1_800_000_000_000u64); + } + + /// The numeric values are the ABI contract with the Swift/Kotlin + /// mirrors (there is no compile-time check across the boundary), so + /// pin them explicitly rather than trusting declaration order. + #[test] + fn dpns_marketplace_codes_are_pinned_at_37_through_40() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorDocumentNotForSale as i32, + 37 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorDocumentPriceChanged as i32, + 38 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorInsufficientIdentityCredits as i32, + 39 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorContestedNameNotTradable as i32, + 40 + ); + } + #[test] fn message_signing_failed_falls_through_to_unknown() { let internal = PlatformWalletError::MessageSigningFailed { diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 5d80c33ded5..a6df9e830d6 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -29,6 +29,9 @@ pub mod derive_and_persist_callbacks; pub mod derive_identity_key_at_slot; pub mod document; pub mod dpns; +pub mod dpns_marketplace; +pub mod dpns_name_state_persistence; +pub mod dpns_sync; pub mod error; pub mod established_contact; pub mod event_handler; @@ -101,6 +104,9 @@ pub use derive_and_persist_callbacks::*; pub use derive_identity_key_at_slot::*; pub use document::*; pub use dpns::*; +pub use dpns_marketplace::*; +pub use dpns_name_state_persistence::*; +pub use dpns_sync::*; pub use error::*; pub use established_contact::*; pub use event_handler::*; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 72026484c3a..3ffac6bb132 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -45,6 +45,9 @@ use crate::contact_persistence::{ use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; +use crate::dpns_name_state_persistence::{ + build_dpns_name_state_entries, free_dpns_name_state_entries, DpnsNameStateFFI, +}; use crate::identity_persistence::{ free_identity_entry_ffi, free_identity_key_entry_ffi, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, @@ -769,6 +772,31 @@ pub struct PersistenceCallbacks { count: usize, ) -> i32, >, + /// Forwards `DpnsNameStateChangeSet` — the DPNS username-marketplace + /// rows (a name's listed price and whether it is still owned, sold, + /// or transferred away) — to the host. Appended at the END so the + /// struct layout stays stable; a host built against the previous + /// vtable keeps working and simply never sets this slot. + /// + /// Same upserts + tombstones shape as `on_persist_identities_fn`, + /// keyed by the 32-byte DPNS `domain` document id. Unlike the all-POD + /// invitation rows, each [`DpnsNameStateFFI`] owns three C strings + /// that are valid only for the callback window — the host must copy + /// anything it keeps before returning. + /// + /// Returns 0 on success. A non-zero return flips the round's + /// `success` flag to `false` so [`Self::on_changeset_end_fn`] + /// receives the rollback signal. + pub on_persist_dpns_name_states_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const DpnsNameStateFFI, + rows_count: usize, + removed_ptr: *const [u8; 32], + removed_count: usize, + ) -> i32, + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -803,6 +831,7 @@ impl Default for PersistenceCallbacks { on_list_wallet_core_txids_fn: None, on_list_wallet_core_txids_free_fn: None, on_persist_dashpay_payments_fn: None, + on_persist_dpns_name_states_fn: None, #[cfg(feature = "shielded")] on_persist_shielded_notes_fn: None, #[cfg(feature = "shielded")] @@ -956,6 +985,9 @@ impl FFIPersister { if self.callbacks.on_persist_invitations_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::INVITATIONS); } + if self.callbacks.on_persist_dpns_name_states_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DPNS_NAME_STATES); + } let wallet_restore = self.callbacks.on_load_wallet_list_fn.is_some() && self.callbacks.on_load_wallet_list_free_fn.is_some(); if self.callbacks.on_persist_account_registrations_fn.is_some() @@ -1563,6 +1595,56 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Send the DPNS username-marketplace changeset — one upsert row + // per tracked `domain` document (keyed by document id) plus + // document-id tombstones. Maps onto the host's DPNS-name rows, + // whose marketplace columns (price, sale status, counterparty) + // these rows own. + // + // Fires AFTER the identities callback so a brand-new identity's + // row is already staged in the same round when the host resolves + // a marketplace row's owning identity. + if let Some(ref dpns_cs) = changeset.dpns_name_states { + if let Some(cb) = self.callbacks.on_persist_dpns_name_states_fn { + let upsert_refs: Vec<&platform_wallet::changeset::DpnsNameStateEntry> = + dpns_cs.names.values().collect(); + let mut upserts = build_dpns_name_state_entries(&upsert_refs); + let removed: Vec<[u8; 32]> = + dpns_cs.removed.iter().map(|id| id.to_buffer()).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Release the per-row label strings on EVERY path, + // including the callback-reported-failure one, before + // the Vec drops its storage. + unsafe { free_dpns_name_state_entries(&mut upserts) }; + if result != 0 { + eprintln!( + "DPNS name state persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Send DashPay contact-request changeset. // // The flat upsert array is built by walking every source @@ -6065,19 +6147,20 @@ mod tests { // terminal — growth is only safe while it happens at the end, where no // previously-defined slot changes offset. The count moves with each // append (invitations, then the `release_fn` context destructor, the - // txid enumeration pair, now the DashPay payment persist slot). + // txid enumeration pair, the DashPay payment persist slot, now the + // DPNS name-state persist slot). #[cfg(not(feature = "shielded"))] assert_eq!( std::mem::size_of::(), - 25 * std::mem::size_of::() + 26 * std::mem::size_of::() ); #[cfg(feature = "shielded")] assert_eq!( std::mem::size_of::(), - 41 * std::mem::size_of::() + 42 * std::mem::size_of::() ); assert_eq!( - std::mem::offset_of!(PersistenceCallbacks, on_persist_dashpay_payments_fn) + std::mem::offset_of!(PersistenceCallbacks, on_persist_dpns_name_states_fn) + std::mem::size_of::(), std::mem::size_of::() ); diff --git a/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md b/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md index 4a5f8c8dc3f..689b2e719c1 100644 --- a/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md +++ b/packages/rs-platform-wallet/docs/DPNS_MARKETPLACE.md @@ -1,10 +1,17 @@ # DPNS Username Marketplace — wallet-level design -Status: implementation in progress (2026-08-09). This document is the design -record for the wallet-level DPNS marketplace layer in `rs-platform-wallet`, -its FFI surface, and the swift-sdk wrappers. It also records the -browse-for-sale investigation result (§7), which is a protocol limitation the -wallet cannot work around. +Status: implemented and testnet-verified (2026-08-09, §9). This document is +the design record for the wallet-level DPNS marketplace layer in +`rs-platform-wallet`, its FFI surface, and the swift-sdk wrappers. It also +records the browse-for-sale investigation result (§7), which is a protocol +limitation the wallet cannot work around. + +Known follow-ups (deliberately out of v1 scope): an FFI event slot for +`on_dpns_marketplace_sync_completed` (there is no host-callback sibling for +any coordinator-completion event today — hosts poll +`dpnsLastSyncUnixSeconds()` or call `syncDpnsMarketplace()` on demand), and +per-name detail across the sync FFI (counts only; the mirrored rows carry +the detail). ## 1. Scope @@ -197,23 +204,30 @@ pub struct DpnsNameHistoryEvent { ## 6. Typed errors -New `PlatformWalletError` variants (with FFI codes from the free registry -slots, mirrored in `PlatformWalletResultCode` + `PlatformWalletError` (Swift)): +New `PlatformWalletError` variants, mirrored in `PlatformWalletResultCode` ++ `PlatformWalletError` (Swift). The FFI codes occupy a fresh contiguous +block 37-40 above every prior claim (the same rule that moved the 34-36 +trio there, rather than reusing vacated slots 28/30); `DpnsNameNotFound` +rides the existing `NotFound = 98`: -| Variant | Trigger | FFI detail payload (JSON in `message`) | -|---|---|---| -| `DpnsNameNotFound { name }` | exact-label query empty | — | -| `DocumentNotForSale { document_id }` | pre-check, or 40108 downcast | — | -| `DocumentPriceChanged { document_id, expected, actual }` | pre-check, or 40109 downcast | `{"expected":u64,"actual":u64}` | -| `InsufficientIdentityCredits { identity_id, required, available }` | pre-check, or `IdentityInsufficientBalanceError` downcast | `{"required":u64,"available":u64}` | -| `ContestedNameNotTradable { label, ends_at_ms }` | contested guard | `{"endsAtMs":u64}` | +| Variant | FFI code | Trigger | FFI detail payload (JSON in `message`) | +|---|---|---|---| +| `DpnsNameNotFound { name }` | 98 `NotFound` | exact-label query empty | — (Display) | +| `DocumentNotForSale { document_id }` | 37 | pre-check, or 40108 downcast | — (Display) | +| `DocumentPriceChanged { document_id, expected, actual }` | 38 | pre-check, or 40109 downcast | `{"documentId":"","expected":u64,"actual":u64}` | +| `InsufficientIdentityCredits { identity_id, required, available }` | 39 | pre-check, or `IdentityInsufficientBalanceError` downcast | `{"identityId":"","required":u64,"available":u64}` | +| `ContestedNameNotTradable { label, ends_at_ms }` | 40 | contested guard | `{"label":"","endsAtMs":u64}` | Downcast helpers (`as_document_not_for_sale`, `as_incorrect_purchase_price`, `as_identity_insufficient_balance`) follow the existing `as_address_invalid_nonce` pattern so consensus rejections arrive typed, not -stringly. The structured-JSON `message` convention for value-carrying codes is -documented at the FFI enum and parsed by swift-sdk into typed Swift cases -(fallback: raw string). +stringly. The structured-JSON `message` convention for the three +value-carrying codes is documented on each FFI enum variant and parsed by +swift-sdk into typed Swift cases (`priceChanged`, +`insufficientIdentityCredits`, `contestedNameNotTradable`); a payload that +fails to parse degrades to `.unknown(message)` rather than trapping. Each +detail object carries the id/label alongside the numbers so a host that +surfaces the error out of band still knows which name it refers to. ## 7. Browse-for-sale: protocol limitation (investigated, not buildable here) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 75d722c54ab..4e5fa19a8e3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -208,6 +208,16 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// - `PersistentTokenBalance.balance` remains the original `Int64` SwiftData /// property and SQLite column. Protocol `u64` values use its raw bits via a /// computed accessor, so full-domain support does not alter this V1 schema. +/// - `PersistentDPNSName` gained the DPNS username-marketplace +/// columns `documentIdBase58`, `priceCredits`, `saleStatusRaw`, +/// `counterpartyIdBase58`, and `marketplaceUpdatedAt`, written by +/// the new `on_persist_dpns_name_states_fn` persister callback +/// (`DpnsNameStateFFI`). All optional or defaulted, and the +/// `(networkRaw, normalizedParentDomainName, normalizedLabel)` +/// uniqueness is unchanged ⇒ lightweight migration. Existing rows +/// migrate with a nil `documentIdBase58`, which is the documented +/// "no marketplace state tracked" signal — the next marketplace +/// sync pass fills them in. /// Each of those is a destructive change to a unique-attribute /// column or to relationship topology, so any pre-existing dev /// store will fail to open and get rebuilt from scratch on next diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift index 67861cb9c44..110d4738d1a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift @@ -70,6 +70,47 @@ public final class PersistentDPNSName { /// `DpnsNameInfo.acquired_at`. `0` when unknown. public var acquiredAt: UInt64 + // MARK: - Username marketplace + // + // Fed by the `on_persist_dpns_name_states_fn` persister callback + // (`DpnsNameStateFFI`), NOT by the identity label snapshot that + // populates the fields above. All of them are optional or defaulted + // so an existing store migrates in place (SwiftData lightweight + // migration). + // + // READ CONTRACT: every field in this section is meaningful only + // while `documentIdBase58` is non-nil. A nil document id means the + // wallet is not tracking this name's marketplace state — it does NOT + // mean the name is owned and unlisted. Gate any marketplace UI on + // `documentIdBase58 != nil` before reading `saleStatus` or + // `priceCredits`. + + /// Base58 id of the DPNS `domain` document behind this label — the + /// handle every trade transition needs, stable across transfers and + /// purchases. `nil` while no marketplace state has been mirrored (or + /// after the row was dropped from marketplace tracking). + public var documentIdBase58: String? + + /// Listed sale price in **credits** (1 duff = 1000 credits), stored + /// as `Int64(bitPattern:)` like `PersistentIdentity.balance` because + /// SwiftData has no unsigned 64-bit column. `nil` = the name is not + /// listed for sale, which is distinct from a 0-credit listing. + public var priceCredits: Int64? + + /// Raw ``DpnsNameSaleStatus`` discriminant: 0 = owned, 1 = sold, + /// 2 = transferred. Defaults to 0 so existing rows migrate, so read + /// it through ``saleStatus`` rather than directly. + public var saleStatusRaw: Int16 + + /// Base58 id of the counterparty a departed name went to — the buyer + /// when `saleStatusRaw == 1`, the recipient when it is 2. `nil` while + /// the name is still owned (or the counterparty is unknown). + public var counterpartyIdBase58: String? + + /// Unix-millis timestamp of the sync pass / confirmed transition + /// that last wrote the marketplace fields. `0` = never written. + public var marketplaceUpdatedAt: UInt64 + // MARK: - Relationships /// Owning identity. Cascade-deleted from the parent — losing the @@ -103,11 +144,63 @@ public final class PersistentDPNSName { self.parentDomainName = parentDomainName self.normalizedParentDomainName = Self.normalize(parentDomainName) self.acquiredAt = acquiredAt + // A freshly inserted row carries no marketplace state until the + // marketplace persister callback writes it — hence a nil document + // id, which is the "not tracked" signal the read contract above + // documents. + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.marketplaceUpdatedAt = 0 self.createdAt = Date() self.lastUpdated = Date() } } +// MARK: - Marketplace accessors + +extension PersistentDPNSName { + /// Typed view of the marketplace columns as the SDK's + /// ``DpnsNameSaleStatus``, or `nil` when this row carries no + /// trustworthy marketplace state. + /// + /// Prefer this over reading `saleStatusRaw` directly: it enforces the + /// read contract, so an untracked row (`documentIdBase58 == nil`) can + /// never be mistaken for an owned-and-unlisted one. It also returns + /// `nil` for a departed row whose counterparty id is missing or + /// undecodable — the wallet always attaches one for a sale or a + /// transfer, so its absence means the row is unreliable, not that the + /// name went nowhere. + public var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return .owned + } + } + + /// The departed name's counterparty as a 32-byte identifier, decoded + /// from `counterpartyIdBase58`. `nil` while the name is still owned, + /// or if the stored string doesn't decode. + public var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + /// Listed sale price in credits, or `nil` when the name is not + /// listed (or carries no mirrored marketplace state at all). + public var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + // MARK: - Normalization extension PersistentDPNSName { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift new file mode 100644 index 00000000000..142eb73be0e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift @@ -0,0 +1,647 @@ +import Foundation +import DashSDKFFI + +// MARK: - Value types + +/// A DPNS name read off Platform with its marketplace state: the domain +/// document id every trade transition needs, and the listed `$price`. +/// +/// Prices are **credits** (1 duff = 1000 credits). `priceCredits == nil` +/// means the name is NOT for sale — distinct from a 0-credit listing. +/// Timestamps are Unix milliseconds and `nil` when the document doesn't +/// carry them, so a UI shows "unknown" rather than the epoch. +public struct DpnsMarketplaceName: Sendable, Equatable { + /// The DPNS `domain` document id — stable across transfers and + /// purchases. + public let documentId: Data + /// The document's owner: the identity that owns (and may sell) the + /// name. + public let ownerId: Data + /// `records.identity` — the identity the name resolves to. The + /// protocol rewrites it to the new owner on purchase/transfer. + public let recordsIdentityId: Data? + /// Display label, e.g. "Alice". + public let label: String + /// Homograph-normalized label, e.g. "a11ce". + public let normalizedLabel: String + /// Listed sale price in credits. `nil` = not for sale. + public let priceCredits: UInt64? + /// Document `$createdAt` in Unix ms, when carried. + public let createdAtMs: UInt64? + /// Document `$updatedAt` in Unix ms — bumps on price changes. + public let updatedAtMs: UInt64? + /// Document `$transferredAt` in Unix ms — set on purchase/transfer. + public let transferredAtMs: UInt64? + + public init( + documentId: Data, + ownerId: Data, + recordsIdentityId: Data?, + label: String, + normalizedLabel: String, + priceCredits: UInt64?, + createdAtMs: UInt64?, + updatedAtMs: UInt64?, + transferredAtMs: UInt64? + ) { + self.documentId = documentId + self.ownerId = ownerId + self.recordsIdentityId = recordsIdentityId + self.label = label + self.normalizedLabel = normalizedLabel + self.priceCredits = priceCredits + self.createdAtMs = createdAtMs + self.updatedAtMs = updatedAtMs + self.transferredAtMs = transferredAtMs + } +} + +/// Where a tracked DPNS name stands relative to the wallet identity that +/// owned it. `Sold` / `Transferred` rows are retained (not deleted) so +/// the host can surface "your name was sold" affordances. +public enum DpnsNameSaleStatus: Sendable, Equatable { + /// The wallet identity still owns the name. + case owned + /// The name left through a purchase; the associated value is the + /// buyer. + case sold(to: Data) + /// The name left through a plain transfer (gift / off-market + /// handover); the associated value is the recipient. + case transferred(to: Data) +} + +/// One locally persisted marketplace row: a name tracked for a wallet +/// identity, with its last-known sale state. +/// +/// Unlike ``DpnsMarketplaceName`` this is the wallet's own bookkeeping +/// (no network read), so it names the wallet identity and — for names +/// that already left — the counterparty, rather than the live document's +/// owner. +public struct DpnsNameStateRow: Sendable, Equatable { + /// The DPNS `domain` document id — this row's key. + public let documentId: Data + /// The wallet identity this row is tracked for. For `.owned` rows the + /// current owner; otherwise the previous owner (ours). + public let walletIdentityId: Data + /// Display label, e.g. "Alice". + public let label: String + /// Homograph-normalized label, e.g. "a11ce". + public let normalizedLabel: String + /// Last-known listed price in credits. `nil` = not for sale. + public let priceCredits: UInt64? + /// Ownership status relative to `walletIdentityId`. + public let status: DpnsNameSaleStatus + /// Document `$createdAt` in Unix ms, when carried. + public let createdAtMs: UInt64? + /// Document `$updatedAt` in Unix ms, when carried. + public let updatedAtMs: UInt64? + /// Document `$transferredAt` in Unix ms, when carried. + public let transferredAtMs: UInt64? + /// Unix ms of the sync pass / confirmed transition that wrote this + /// row. + public let lastSyncedAtMs: UInt64 + + public init( + documentId: Data, + walletIdentityId: Data, + label: String, + normalizedLabel: String, + priceCredits: UInt64?, + status: DpnsNameSaleStatus, + createdAtMs: UInt64?, + updatedAtMs: UInt64?, + transferredAtMs: UInt64?, + lastSyncedAtMs: UInt64 + ) { + self.documentId = documentId + self.walletIdentityId = walletIdentityId + self.label = label + self.normalizedLabel = normalizedLabel + self.priceCredits = priceCredits + self.status = status + self.createdAtMs = createdAtMs + self.updatedAtMs = updatedAtMs + self.transferredAtMs = transferredAtMs + self.lastSyncedAtMs = lastSyncedAtMs + } +} + +/// One event in a DPNS name's trade timeline, assembled from the +/// Document History system contract plus the domain document's own +/// creation time. Prices are credits; `atMs` is Unix milliseconds. +public enum DpnsNameHistoryEvent: Sendable, Equatable { + /// The domain document was registered. + case registered(atMs: UInt64) + /// The owner listed or re-priced the name. + case priceSet(price: UInt64, atMs: UInt64, blockHeight: UInt64?) + /// The name was purchased: `seller` received `price` credits from + /// `buyer`, who became the owner. + case purchased(price: UInt64, seller: Data, buyer: Data, atMs: UInt64, blockHeight: UInt64?) + /// The name was transferred without payment — a gift/handover, or a + /// transfer-to-self delist when `from == to`. + case transferred(from: Data, to: Data, atMs: UInt64, blockHeight: UInt64?) + + /// Block time of the event in Unix ms, whatever the case. + public var atMs: UInt64 { + switch self { + case .registered(let atMs): + return atMs + case .priceSet(_, let atMs, _), .transferred(_, _, let atMs, _): + return atMs + case .purchased(_, _, _, let atMs, _): + return atMs + } + } +} + +/// Per-pass delta returned by +/// ``ManagedPlatformWallet/syncDpnsMarketplace()``. +public struct DpnsMarketplaceSyncSummary: Sendable, Equatable { + /// Owned-name rows refreshed this pass. + public let tracked: UInt32 + /// Labels newly observed on a wallet identity. + public let added: UInt32 + /// Names that left a wallet identity (sold or transferred away). + public let departed: UInt32 + /// Listed-price changes since the previous pass. + public let pricesChanged: UInt32 + + public init(tracked: UInt32, added: UInt32, departed: UInt32, pricesChanged: UInt32) { + self.tracked = tracked + self.added = added + self.departed = departed + self.pricesChanged = pricesChanged + } +} + +// MARK: - FFI decoding + +extension DpnsMarketplaceName { + /// Copy a Rust-owned row into an owned Swift value. Every `has_*` + /// flag gates its field: a `false` flag becomes `nil`, never the + /// zero the FFI struct happens to hold. Zero timestamps mean + /// "unknown" on this boundary and decode to `nil` for the same + /// reason. + /// + /// Must be called while the Rust allocation is still alive — the + /// label strings are copied here, not retained. + init(ffi: DpnsMarketplaceNameFFI) { + var documentTuple = ffi.document_id + var ownerTuple = ffi.owner_id + var recordsTuple = ffi.records_identity_id + self.init( + documentId: Swift.withUnsafeBytes(of: &documentTuple) { Data($0) }, + ownerId: Swift.withUnsafeBytes(of: &ownerTuple) { Data($0) }, + recordsIdentityId: ffi.has_records_identity + ? Swift.withUnsafeBytes(of: &recordsTuple) { Data($0) } + : nil, + label: ffi.label.map { String(cString: $0) } ?? "", + normalizedLabel: ffi.normalized_label.map { String(cString: $0) } ?? "", + priceCredits: ffi.has_price ? ffi.price : nil, + createdAtMs: ffi.created_at_ms == 0 ? nil : ffi.created_at_ms, + updatedAtMs: ffi.updated_at_ms == 0 ? nil : ffi.updated_at_ms, + transferredAtMs: ffi.transferred_at_ms == 0 ? nil : ffi.transferred_at_ms + ) + } +} + +extension DpnsNameStateRow { + /// Copy a Rust-owned persisted row into an owned Swift value. + /// + /// An unrecognised `status` byte decodes to `.owned` with a + /// counterparty of `nil` only when `has_counterparty` is false; + /// otherwise it is treated as `.transferred`, the wallet layer's own + /// documented fallback for an unattributable departure — the row is + /// never reported as a sale the wallet cannot evidence. + init(ffi: DpnsNameStateRowFFI) { + var documentTuple = ffi.document_id + var walletIdentityTuple = ffi.wallet_identity_id + var counterpartyTuple = ffi.counterparty_id + let counterparty: Data? = ffi.has_counterparty + ? Swift.withUnsafeBytes(of: &counterpartyTuple) { Data($0) } + : nil + let status: DpnsNameSaleStatus + switch (ffi.status, counterparty) { + case (1, .some(let to)): + status = .sold(to: to) + case (_, .some(let to)): + status = .transferred(to: to) + default: + status = .owned + } + self.init( + documentId: Swift.withUnsafeBytes(of: &documentTuple) { Data($0) }, + walletIdentityId: Swift.withUnsafeBytes(of: &walletIdentityTuple) { Data($0) }, + label: ffi.label.map { String(cString: $0) } ?? "", + normalizedLabel: ffi.normalized_label.map { String(cString: $0) } ?? "", + priceCredits: ffi.has_price ? ffi.price : nil, + status: status, + createdAtMs: ffi.created_at_ms == 0 ? nil : ffi.created_at_ms, + updatedAtMs: ffi.updated_at_ms == 0 ? nil : ffi.updated_at_ms, + transferredAtMs: ffi.transferred_at_ms == 0 ? nil : ffi.transferred_at_ms, + lastSyncedAtMs: ffi.last_synced_at_ms + ) + } +} + +extension DpnsNameHistoryEvent { + /// Copy a Rust-owned timeline row into an owned Swift value. + /// Returns `nil` for a `kind` byte this build doesn't know, or for a + /// row missing a payload its kind requires — an unreadable event is + /// dropped from the timeline rather than rendered with invented + /// values. + init?(ffi: DpnsNameHistoryEventFFI) { + var fromTuple = ffi.from_id + var toTuple = ffi.to_id + let from: Data? = ffi.has_from + ? Swift.withUnsafeBytes(of: &fromTuple) { Data($0) } + : nil + let to: Data? = ffi.has_to ? Swift.withUnsafeBytes(of: &toTuple) { Data($0) } : nil + let blockHeight: UInt64? = ffi.has_block_height ? ffi.block_height : nil + let price: UInt64? = ffi.has_price ? ffi.price : nil + + switch ffi.kind { + case 0: + self = .registered(atMs: ffi.at_ms) + case 1: + guard let price else { return nil } + self = .priceSet(price: price, atMs: ffi.at_ms, blockHeight: blockHeight) + case 2: + guard let price, let from, let to else { return nil } + self = .purchased( + price: price, + seller: from, + buyer: to, + atMs: ffi.at_ms, + blockHeight: blockHeight + ) + case 3: + guard let from, let to else { return nil } + self = .transferred(from: from, to: to, atMs: ffi.at_ms, blockHeight: blockHeight) + default: + return nil + } + } +} + +// MARK: - ManagedPlatformWallet operations + +extension ManagedPlatformWallet { + /// Prefix-search DPNS names on Platform, with each hit's full + /// marketplace state (document id, owner, `$price`, timestamps). + /// + /// An empty `prefix` is a valid alphabetical browse. `limit == 0` + /// uses the wallet's default page size. `startAfter` is the cursor: + /// pass the previous page's last `documentId` to fetch the next page. + /// + /// There is no server-side price filter or ordering — `$price` is not + /// an indexable system property on Dash Platform, so a global + /// "everything for sale, cheapest first" query is not buildable at + /// any layer today. The marketplace is search-driven. + public func searchDpnsMarketplace( + prefix: String, + limit: UInt32 = 0, + startAfter: Data? = nil + ) async throws -> [DpnsMarketplaceName] { + let handle = self.handle + let cursorBytes: [UInt8]? = startAfter.map { Array($0) } + return try await Task.detached(priority: .userInitiated) { () -> [DpnsMarketplaceName] in + var outPtr: UnsafeMutablePointer? = nil + var outCount: UInt = 0 + let result = prefix.withCString { prefixPtr -> PlatformWalletFFIResult in + Self.withOptionalBytes(cursorBytes) { cursorPtr in + platform_wallet_dpns_marketplace_search( + handle, + prefixPtr, + limit, + cursorPtr, + &outPtr, + &outCount + ) + } + } + try result.check() + guard let ptr = outPtr, outCount > 0 else { return [] } + defer { dpns_marketplace_names_free(ptr, outCount) } + return (0..