From f647d58251aaa2f722beac57722b87f1c571eabe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 15 Jun 2026 22:18:22 +0100 Subject: [PATCH 1/5] feat(swift-sdk): add production create-document flow via platform-wallet FFI Add a real, schema-driven "Create Document" flow to SwiftExampleApp that broadcasts a DocumentCreate through rs-platform-wallet using the wallet's keychain-backed signer, replacing the local-only mock (DOC-09) and complementing the test-signer builder path. - rs-platform-wallet: IdentityWallet::create_document_with_signer fetches the on-chain contract, builds a rev-1 Document, selects an AUTHENTICATION+ECDSA key satisfying the doc type's security_level_requirement(), and broadcasts via the SDK's PutDocument::put_to_platform_and_wait_for_response on the 8 MB worker stack. - rs-platform-wallet-ffi: platform_wallet_create_document_with_signer C ABI. - swift-sdk: ManagedPlatformWallet.createDocument wrapper + schema-driven CreateDocumentView (owner-identity picker + DocumentFieldsView), reachable from Contracts -> contract -> document type -> New Document. Confirmed document persisted to SwiftData so it appears in the documents list. Also fixes a navigation-stability bug that made the create flow (and other Contracts drill-downs) unusable: background-sync SwiftData writes invalidated the contracts @Query list whose ForEach rows owned the details sheet, tearing down the contract-details navigation ~every second (compounded by deprecated NavigationView/NavigationLink(destination:) and auto-refreshing relative-time Text). Hoisted the details sheet to a stable container, migrated to value-based navigationDestination, made relative-time strings static, and decoupled the root TabView from SPV progress ticks. Verified end-to-end on the iPhone 17 simulator (testnet): created a preorder document on the DPNS contract GWRSAV...S31Ec from a funded identity; network-confirmed, doc id 7i1hJgvVt8fJms26kGwkEZ6jVZxrfd3BrqfmAfpqXMoG, ZPERSISTENTDOCUMENT 0->1. build_ios.sh --target sim succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rs-platform-wallet-ffi/src/document.rs | 91 ++++ packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../src/wallet/identity/network/document.rs | 332 ++++++++++++++ .../src/wallet/identity/network/mod.rs | 1 + .../ManagedPlatformWallet.swift | 80 ++++ .../SwiftExampleApp/ContentView.swift | 44 +- .../Views/ContractsTabView.swift | 21 +- .../Views/DataContractDetailsView.swift | 59 ++- .../Views/DocumentFieldsView.swift | 8 + .../Views/DocumentTypeDetailsView.swift | 31 ++ .../SwiftExampleApp/Views/DocumentsView.swift | 434 ++++++++++++++---- .../Views/LocalDataContractsView.swift | 38 +- .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 7 +- 13 files changed, 1029 insertions(+), 119 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/document.rs create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/document.rs diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs new file mode 100644 index 00000000000..3a4cfc5fbe7 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -0,0 +1,91 @@ +//! FFI bindings for document create operations on `IdentityWallet`. + +use std::ffi::CStr; +use std::os::raw::c_char; +use std::slice; + +use dpp::document::DocumentV0Getters; +use dpp::prelude::Identifier; +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}; + +/// Create + broadcast a new document on `contract_id`'s +/// `document_type_name`, owned by `owner_identity_id`, signed via the +/// external `signer_handle`. +/// +/// Goes through `IdentityWallet::create_document_with_signer`, which +/// fetches the on-chain contract, builds a revision-1 document from the +/// supplied `properties_json`, selects an AUTHENTICATION + ECDSA key +/// from the in-process `IdentityManager` whose security level satisfies +/// the document type's requirement, broadcasts on the platform-wallet +/// 8 MB worker stack (required to avoid the GroveDB proof-verification +/// stack overflow), and waits for the confirmed document. +/// +/// On success the confirmed document's 32-byte id is written to +/// `out_document_id`. The signature never crosses into Swift logic — +/// it routes back through the supplied `signer_handle` (typically +/// `KeychainSigner.handle`); the caller retains ownership of the +/// signer. +/// +/// `properties_json` is a NUL-terminated UTF-8 JSON object keyed by +/// property name. Byte-array fields are passed as hex (or base64) +/// strings and identifier fields as base58 (or hex) strings; the +/// schema-driven sanitize step on the Rust side converts them to the +/// protocol's native types. Pass `"{}"` for a document type with no +/// required properties. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_create_document_with_signer( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + properties_json: *const c_char, + signer_handle: *mut SignerHandle, + out_document_id: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(signer_handle); + check_ptr!(document_type_name); + check_ptr!(properties_json); + check_ptr!(out_document_id); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + let properties_str = unwrap_result_or_return!(CStr::from_ptr(properties_json).to_str()); + + let signer_addr = signer_handle as usize; + let owner_id_for_async = owner_id; + let contract_id_for_async = contract_id_value; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .create_document_with_signer( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + properties_str, + signer, + ) + .await + .map(|document| document.id()) + }); + result + }); + let result = unwrap_option_or_return!(option); + let document_id = unwrap_result_or_return!(result); + let bytes = document_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 1b5d0117a4d..74897c6df09 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -23,6 +23,7 @@ pub mod data_contract; pub mod derivation; pub mod derive_and_persist_callbacks; pub mod derive_identity_key_at_slot; +pub mod document; pub mod dpns; pub mod error; pub mod established_contact; @@ -88,6 +89,7 @@ pub use data_contract::*; pub use derivation::*; pub use derive_and_persist_callbacks::*; pub use derive_identity_key_at_slot::*; +pub use document::*; pub use dpns::*; pub use error::*; pub use established_contact::*; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs new file mode 100644 index 00000000000..38d48ead050 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -0,0 +1,332 @@ +//! Document create operations on `IdentityWallet`. +//! +//! Lives on `IdentityWallet` (rather than in `rs-sdk-ffi`) for the +//! same reason as `contract.rs`: creating + broadcasting a document is +//! a wallet-level operation. It spans an identity (the owner), needs +//! the wallet's external signer, and broadcasts a document state +//! transition whose signature key is selected from the in-memory +//! wallet manager. Per `swift-sdk/CLAUDE.md`, "anything that spans +//! identities / platform balances / ... belongs in the +//! `platform-wallet` crate"; the Swift side only renders the form, +//! marshals the values, and persists the confirmed document. +//! +//! Mirrors the post-#3541 identity-flow shape: +//! - The library function takes a `Signer` +//! reference so the FFI's external `KeychainSigner` trampoline can +//! route signing back to Swift / Keychain without crossing seed +//! bytes. +//! - The document content arrives as a properties JSON string and is +//! turned into a platform `Value` map, then a revision-1 `Document` +//! via `DocumentType::create_document_from_data` — the same path +//! `rs-sdk-ffi/src/document/create.rs` uses. +//! - Broadcast goes through +//! `dash_sdk::platform::transition::put_document::PutDocument::put_to_platform_and_wait_for_response` +//! on the platform-wallet runtime (8 MB worker stack) instead of +//! the rs-sdk-ffi runtime (mobile-tuned default stack) — the same +//! stack-overflow avoidance `contract.rs` documents for the +//! post-broadcast GroveDB proof-verification recursion. + +use std::collections::BTreeMap; + +use async_trait::async_trait; + +use dpp::address_funds::AddressWitness; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::document::Document; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::{BinaryData, Value}; +use dpp::prelude::{DataContract, Identifier}; +use dpp::ProtocolError; + +use dash_sdk::platform::transition::put_document::PutDocument; +use dash_sdk::platform::Fetch; + +use crate::error::PlatformWalletError; + +use super::*; + +/// Borrowed-signer adapter — same shape as the local `SignerRef` in +/// `contract.rs` / `dpns.rs` / `transfer.rs`. Lets the +/// `Signer` trait bound on the SDK's `PutDocument` +/// extension be satisfied with a `&S` instead of forcing the caller to +/// hand over ownership / wrap in an `Arc` per call. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + +/// Build the set of `SecurityLevel`s an AUTHENTICATION key may carry to +/// satisfy a document state-transition whose document type requires +/// `requirement`. +/// +/// This reproduces the consensus rule the network enforces in +/// `BatchTransition::combined_security_level_requirement` +/// (`rs-dpp/.../batch_transition/methods/v0/mod.rs`): the signing key's +/// security level must be **stronger-or-equal** to the document type's +/// requirement, expressed as the inclusive range `CRITICAL..=requirement` +/// over the `MASTER(0) < CRITICAL(1) < HIGH(2) < MEDIUM(3)` ordering — +/// with `MASTER` handled as its own degenerate `[MASTER]` set. MASTER is +/// otherwise excluded: it is reserved for identity-self-modification and +/// the document-batch purpose requirement (`vec![AUTHENTICATION]`) never +/// admits it for an ordinary document create. +/// +/// Picking the key against this exact set (rather than a hardcoded +/// CRITICAL, which the contract-create path uses) is what makes the +/// 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 { + if requirement == SecurityLevel::MASTER { + return vec![SecurityLevel::MASTER]; + } + // `CRITICAL as u8 == 1`; iterate down to (and including) the + // requirement. `SecurityLevel::try_from` only fails for values + // outside 0..=3, and every value in this range is valid by + // construction. + (SecurityLevel::CRITICAL as u8..=requirement as u8) + .filter_map(|level| SecurityLevel::try_from(level).ok()) + .collect() +} + +impl IdentityWallet { + /// Create a new revision-1 document on `contract_id`'s + /// `document_type_name` owned by `owner_identity_id`, and broadcast + /// it to Platform. + /// + /// The function: + /// 1. Fetches the on-chain `DataContract` for `contract_id` via + /// `self.sdk` and resolves the owned `DocumentType` for + /// `document_type_name`. + /// 2. Parses `properties_json` into a platform `Value` map, + /// sanitizes it against the schema (hex/base64 byte arrays, + /// base58/hex identifiers — same as `rs-sdk-ffi`'s document + /// create), and builds a revision-1 `Document` via + /// `DocumentType::create_document_from_data`. Entropy is + /// generated by the SDK at broadcast time (we pass `None`), so + /// the canonical document id is derived from + /// `(contract, owner, type, entropy)` there — the placeholder + /// id this build step assigns is overwritten. + /// 3. Selects the signing `IdentityPublicKey` from the in-memory + /// wallet manager: an AUTHENTICATION-purpose, ECDSA_SECP256K1 + /// key whose security level satisfies the document type's + /// `security_level_requirement()` (see + /// [`allowed_signing_security_levels`]). Returns a clear error + /// if the owner identity isn't loaded or carries no qualifying + /// key. + /// 4. Broadcasts via + /// `Document::put_to_platform_and_wait_for_response` on the + /// platform-wallet 8 MB-stack worker and returns the confirmed + /// `Document` from Platform. + /// + /// `properties_json` is a JSON object keyed by property name. Byte- + /// array fields are supplied as hex (or base64) strings and + /// identifier fields as base58 (or hex) strings; the schema-driven + /// sanitize step converts them to the protocol's native `Bytes` / + /// `Identifier` values. An empty object (`"{}"`) is valid for a + /// document type with no required properties. + pub async fn create_document_with_signer( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + properties_json: &str, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let platform_version = self.sdk.version(); + + // 1. Fetch the on-chain contract + resolve the document type. + let data_contract = DataContract::fetch(&self.sdk, *contract_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch contract {contract_id} for document create: {e}" + )) + })? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Data contract {contract_id} not found on Platform; cannot create document" + )) + })?; + + // Owned `DocumentType` — `put_to_platform_and_wait_for_response` + // takes the document type by value. + let document_type = data_contract + .document_type_cloned_for_name(document_type_name) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Document type {document_type_name:?} not found on contract {contract_id}: {e}" + )) + })?; + + // 2. Parse properties JSON -> platform Value map, sanitize + // against the schema, and build a revision-1 document. + let properties_value: serde_json::Value = + serde_json::from_str(properties_json).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid document properties JSON: {e}" + )) + })?; + let mut properties: BTreeMap = serde_json::from_value(properties_value) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Document properties must be a JSON object keyed by property name: {e}" + )) + })?; + document_type + .as_ref() + .sanitize_document_properties(&mut properties); + + // Entropy is generated by the SDK at broadcast time (we pass + // `None` to `put_to_platform_and_wait_for_response`), which + // overwrites the document id with the canonical + // `(contract, owner, type, entropy)` derivation. The entropy + // supplied here only feeds the placeholder id, so a fixed zero + // value is fine — it is never broadcast. + let document = document_type + .as_ref() + .create_document_from_data( + properties.into(), + *owner_identity_id, + 0, // block_height — set by Platform + 0, // core_block_height — set by Platform + [0u8; 32], + platform_version, + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to build document: {e}")) + })?; + + // 3. Owner identity + signing key from the wallet manager. The + // document state transition must be signed by an + // AUTHENTICATION + ECDSA key whose security level satisfies + // the document type's requirement (NOT a fixed CRITICAL like + // the contract-create path). + let required_level = document_type.security_level_requirement(); + let allowed_levels = allowed_signing_security_levels(required_level); + let signing_key = { + 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 manager = &info.identity_manager; + let identity = manager + .identity(owner_identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + allowed_levels.iter().copied().collect(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "No ECDSA authentication key at a security level satisfying \ + {required_level} found on owner identity {owner_identity_id} \ + (required to sign a {document_type_name} document state transition)" + )) + })? + .clone() + }; + + // 4. Broadcast via `PutDocument` on the platform-wallet 8 MB + // worker stack. `None` entropy -> the SDK generates entropy + // and the canonical document id for this revision-1 create; + // `None` token-payment-info -> no token gating. + let confirmed = document + .put_to_platform_and_wait_for_response( + &self.sdk, + document_type, + None, + signing_key, + None, + &SignerRef(signer), + None, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to put document to platform: {e}" + )) + })?; + + Ok(confirmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowed_levels_high_requirement_admits_critical_and_high_only() { + // DPNS `preorder` requires HIGH: CRITICAL + HIGH qualify, + // MEDIUM does not, MASTER is excluded. + let levels = allowed_signing_security_levels(SecurityLevel::HIGH); + assert_eq!(levels, vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH]); + } + + #[test] + fn allowed_levels_medium_requirement_admits_critical_high_medium() { + let levels = allowed_signing_security_levels(SecurityLevel::MEDIUM); + assert_eq!( + levels, + vec![ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM + ] + ); + } + + #[test] + fn allowed_levels_critical_requirement_admits_only_critical() { + let levels = allowed_signing_security_levels(SecurityLevel::CRITICAL); + assert_eq!(levels, vec![SecurityLevel::CRITICAL]); + } + + #[test] + fn allowed_levels_master_requirement_is_master_only() { + // Degenerate case mirrored from the consensus rule: a MASTER + // requirement collapses to the single `[MASTER]` set rather + // than the CRITICAL..=MASTER range (which would be empty). + let levels = allowed_signing_security_levels(SecurityLevel::MASTER); + assert_eq!(levels, vec![SecurityLevel::MASTER]); + } +} 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 b4035d3c26d..533973014e1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -18,6 +18,7 @@ // Core handle + identity-lifecycle operations. mod contract; mod discovery; +mod document; mod dpns; mod identity_handle; mod loading; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index c10f771e237..6ec685d3ab5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2559,6 +2559,86 @@ extension ManagedPlatformWallet { }.value } + /// Create + broadcast a new revision-1 document on `contractId`'s + /// `documentType`, owned by `ownerIdentityId`. Returns the 32-byte + /// document id once Platform confirms the transition. + /// + /// Routes through `IdentityWallet::create_document_with_signer` + /// (via `platform_wallet_create_document_with_signer`), the + /// production document-create path. The Rust side fetches the + /// on-chain contract, builds the document from `propertiesJSON`, + /// selects an AUTHENTICATION + ECDSA key whose security level + /// satisfies the document type's requirement, broadcasts on the + /// platform-wallet 8 MB worker stack, and waits for confirmation. + /// This deliberately does NOT use the rs-sdk-ffi test-signer + /// builder path (`dash_sdk_document_create` / + /// `dash_sdk_document_put_to_platform_and_wait`): per + /// `swift-sdk/CLAUDE.md`, the state-transition flow lives in the + /// `platform-wallet` library and the signing key never crosses + /// into Swift logic. + /// + /// `propertiesJSON` is a JSON object keyed by property name. + /// Byte-array fields must be encoded as hex strings and identifier + /// fields as base58 strings (the Rust schema-driven sanitize step + /// converts them to native bytes / identifiers). Pass `"{}"` for a + /// document type with no required properties. + /// + /// Lifetime contract: the `signer` instance MUST stay alive for + /// the duration of the `await` (Rust holds a `passUnretained` + /// ctx pointer to the underlying `KeychainSigner`). A + /// `_ = signer` keepalive at the call site is the canonical way + /// to pin it. + public func createDocument( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + propertiesJSON: String, + signer: KeychainSigner + ) async throws -> Identifier { + let handle = self.handle + let signerHandle = signer.handle + let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let contractBytes: [UInt8] = contractId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + // Pin every borrowed payload across the FFI call: the + // owner-id + contract-id bytes, the document-type name, + // and the properties JSON. Rust dereferences the + // C-string pointers synchronously inside + // `block_on_worker`, so the `withCString` scopes here are + // sufficient — the pointers don't need to outlive the + // call. + _ = signer + var documentIdBytes = [UInt8](repeating: 0, count: 32) + + let result = ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + propertiesJSON.withCString { propsPtr in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_create_document_with_signer( + handle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + propsPtr, + signerHandle, + outBp.baseAddress! + ) + } + } + } + } + } + + try result.check() + return Data(documentIdBytes) + }.value + } + /// Run `body` with a NUL-terminated C string for `value`, or /// `nil` when `value` is nil. Mirrors the `withCString` /// pattern but terminates the chain when the optional is diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 5febd575118..94fe5af4cb2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -12,9 +12,16 @@ struct ContentView: View { let bootstrapError: Error? let onRetry: () -> Void - @EnvironmentObject var walletManager: PlatformWalletManager + // NOTE: `walletManager` and `appUIState` are intentionally NOT + // declared here. An `@EnvironmentObject` subscribes the entire view + // to that object's `objectWillChange`, so holding `walletManager` + // (which publishes `spvProgress` on a fast cadence during sync) on + // this root view re-rendered the whole `TabView` several times a + // second — tearing down each tab's content and any sheet/pushed + // view inside it. Both observations now live in the leaf + // `GlobalSyncIndicatorOverlay` instead, so sync ticks no longer + // invalidate `ContentView.body`. @EnvironmentObject var walletManagerStore: WalletManagerStore - @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformState: AppState @Environment(\.modelContext) private var modelContext @@ -118,10 +125,17 @@ struct ContentView: View { .tag(RootTab.settings) } .overlay(alignment: .top) { - let state = walletManager.spvProgress.overallState - if state == .syncing || state == .waitingForConnections { - GlobalSyncIndicator(showDetails: selectedTab == .sync && appUIState.showWalletsSyncDetails) - } + // The sync indicator depends on `walletManager.spvProgress`, + // which publishes on a fast cadence while syncing. Reading + // it directly in `ContentView.body` would subscribe the + // whole `TabView` to every progress tick, re-creating each + // tab's content (including `ContractsTabView` and any sheet + // it presents) several times a second — which tears down a + // pushed drill-down and dismisses sheets presented from it + // (e.g. the document-create flow). Isolating the volatile + // observation in this leaf keeps the tab content stable; + // only the overlay re-renders on progress. + GlobalSyncIndicatorOverlay(isSyncTab: selectedTab == .sync) } .onAppear { checkForOrphanMnemonic() } .onChange(of: persistentWallets.count) { _, _ in @@ -563,6 +577,24 @@ struct ContentView: View { } } +/// Leaf wrapper that owns the volatile `walletManager` / `appUIState` +/// observations so the sync-progress publish cadence re-renders only +/// this overlay, not the parent `TabView`. `isSyncTab` is passed as a +/// plain value (it changes only on tab switch), keeping this view's +/// only fast-publishing dependency local. +struct GlobalSyncIndicatorOverlay: View { + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var appUIState: AppUIState + let isSyncTab: Bool + + var body: some View { + let state = walletManager.spvProgress.overallState + if state == .syncing || state == .waitingForConnections { + GlobalSyncIndicator(showDetails: isSyncTab && appUIState.showWalletsSyncDetails) + } + } +} + struct GlobalSyncIndicator: View { @EnvironmentObject var walletManager: PlatformWalletManager let showDetails: Bool diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift index 0a64ecdfa4f..c106fa4633e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift @@ -80,6 +80,14 @@ struct ContractsTabView: View { @State private var isLoading = false @State private var errorMessage: String? @State private var showError = false + /// Saved contract whose details sheet is presented. Driven by the + /// row tap and presented from this stable `NavigationStack`/`List` + /// level rather than from inside the per-row `DataContractRow`. A + /// `.sheet` owned by a `ForEach` row is torn down whenever the + /// `@Query` re-runs (sync writes invalidate it continuously), + /// which would collapse a deep drill-down (the document-create + /// flow) presented inside it. Hoisting it here keeps it stable. + @State private var selectedContract: PersistentDataContract? /// Active preview the user is inspecting. Setting this drives the /// sheet presentation; `nil` dismisses the sheet. The struct @@ -175,6 +183,15 @@ struct ContractsTabView: View { .environmentObject(transitionState) .environment(\.modelContext, modelContext) } + .sheet(item: $selectedContract) { contract in + // Saved-contract details. Presented from this stable + // container so a deep drill-down inside it (document + // type -> New Document) isn't torn down when the + // `@Query` list re-renders. + DataContractDetailsView(contract: contract) + .environmentObject(platformState) + .environment(\.modelContext, modelContext) + } .sheet(item: $pendingPreview) { preview in // Render the full saved-contract details view against // the throwaway in-memory container so tokens, document @@ -275,7 +292,9 @@ struct ContractsTabView: View { } else { Section("My Contracts") { ForEach(dataContracts) { contract in - DataContractRow(contract: contract) + DataContractRow(contract: contract) { + selectedContract = contract + } } .onDelete(perform: deleteContracts) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DataContractDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DataContractDetailsView.swift index bb91ac478a2..4a2d671162e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DataContractDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DataContractDetailsView.swift @@ -40,7 +40,15 @@ struct DataContractDetailsView: View { } var body: some View { - NavigationView { + // `NavigationStack` + value-based navigation (rather than the + // deprecated `NavigationView` + `NavigationLink(destination:)`). + // Destination-based links inside a `ForEach` get torn down and + // popped whenever the parent re-renders; a re-render of the + // contracts list (or this view) would otherwise pop a pushed + // drill-down — and dismiss any sheet presented from it, e.g. + // the document-create flow. Value-based routes survive a + // parent re-render. + NavigationStack { List { contractConfigurationSection contractInfoSection @@ -52,6 +60,26 @@ struct DataContractDetailsView: View { } .navigationTitle("Contract Details") .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: PersistentToken.self) { token in + TokenDetailsView(token: token) + } + .navigationDestination(for: PersistentDocumentType.self) { docType in + DocumentTypeDetailsView(documentType: docType) + } + .navigationDestination(for: PersistentDocument.self) { document in + DocumentStorageDetailView(record: document) + } + .navigationDestination(for: ParsedGroup.self) { group in + GroupDetailView( + contractId: contract.id, + position: group.position, + members: group.members, + requiredPower: group.requiredPower + ) + } + .navigationDestination(for: OwnerRoute.self) { route in + IdentityDetailView(identityId: route.identityId) + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { @@ -166,7 +194,7 @@ struct DataContractDetailsView: View { if let tokens = contract.tokens, !tokens.isEmpty { Section("Tokens (\(tokens.count))") { ForEach(tokens.sorted(by: { $0.position < $1.position }), id: \.id) { token in - NavigationLink(destination: TokenDetailsView(token: token)) { + NavigationLink(value: token) { TokenRowView(token: token) } } @@ -179,7 +207,7 @@ struct DataContractDetailsView: View { if let documentTypes = contract.documentTypes, !documentTypes.isEmpty { Section("Document Types (\(documentTypes.count))") { ForEach(documentTypes.sorted(by: { $0.name < $1.name }), id: \.id) { docType in - NavigationLink(destination: DocumentTypeDetailsView(documentType: docType)) { + NavigationLink(value: docType) { DocumentTypeRowView(docType: docType) } } @@ -198,7 +226,7 @@ struct DataContractDetailsView: View { @ViewBuilder private var ownerRow: some View { if let owner = contract.ownerIdentity { - NavigationLink(destination: IdentityDetailView(identityId: owner.identityId)) { + NavigationLink(value: OwnerRoute(identityId: owner.identityId)) { HStack { Text("Owner:") .foregroundColor(.secondary) @@ -227,7 +255,7 @@ struct DataContractDetailsView: View { let sortedDocs = contract.documents.sorted(by: { $0.documentId < $1.documentId }) Section("Documents (\(contract.documents.count))") { ForEach(sortedDocs, id: \.documentId) { document in - NavigationLink(destination: DocumentStorageDetailView(record: document)) { + NavigationLink(value: document) { DocumentInstanceRowView(document: document) } } @@ -244,14 +272,7 @@ struct DataContractDetailsView: View { if let parsedGroups = parseGroups(), !parsedGroups.isEmpty { Section("Groups (\(parsedGroups.count))") { ForEach(parsedGroups, id: \.position) { group in - NavigationLink( - destination: GroupDetailView( - contractId: contract.id, - position: group.position, - members: group.members, - requiredPower: group.requiredPower - ) - ) { + NavigationLink(value: group) { GroupRowView( position: group.position, memberCount: group.members.count, @@ -303,12 +324,22 @@ struct DataContractDetailsView: View { /// Flat group entry used by `groupsSection` / `GroupDetailView`. /// Decoupled from the on-row `[String: Any]` shape so the /// section view doesn't have to re-parse on every row render. - private struct ParsedGroup { + /// `Hashable` so it can drive value-based `.navigationDestination` + /// (synthesized — `GroupMember` is already `Hashable`). + private struct ParsedGroup: Hashable { let position: Int let requiredPower: Int let members: [GroupMember] } + /// Value-based navigation route for the owner-identity drill-down. + /// A thin wrapper around the owner's `identityId` so the + /// `.navigationDestination(for:)` route type is unambiguous (a bare + /// `Data` value could collide with any other `Data`-keyed route). + private struct OwnerRoute: Hashable { + let identityId: Data + } + /// Decode `contract.groups` (`[String: Any]?`) into a sorted /// list of `ParsedGroup` rows. The on-chain shape is: /// `{ "": { "members": { "": }, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index 1012a3f2a01..6d12757d08d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -56,6 +56,7 @@ struct DocumentFieldsView: View { TextField("Base58 identifier", text: binding(for: property.name, in: $textFields)) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("createDocument.field.\(property.name)") Text("Enter a valid base58 identifier (e.g., 4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF)") .font(.caption2) .foregroundColor(.secondary) @@ -65,17 +66,20 @@ struct DocumentFieldsView: View { case "string": TextField(placeholderText(for: property), text: binding(for: property.name, in: $textFields)) .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier("createDocument.field.\(property.name)") case "number", "integer": TextField(placeholderText(for: property), text: binding(for: property.name, in: $numberFields)) .keyboardType(.numberPad) .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier("createDocument.field.\(property.name)") case "boolean": Toggle(isOn: binding(for: property.name, in: $boolFields)) { Text("") } .labelsHidden() + .accessibilityIdentifier("createDocument.field.\(property.name)") case "array": if property.byteArray { @@ -86,6 +90,7 @@ struct DocumentFieldsView: View { VStack(alignment: .leading, spacing: 4) { TextField("Enter comma-separated values", text: binding(for: property.name, in: $arrayFields)) .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier("createDocument.field.\(property.name)") Text("Separate multiple values with commas") .font(.caption2) .foregroundColor(.secondary) @@ -100,10 +105,12 @@ struct DocumentFieldsView: View { RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.3), lineWidth: 1) ) + .accessibilityIdentifier("createDocument.field.\(property.name)") default: TextField("Enter \(property.name)", text: binding(for: property.name, in: $textFields)) .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier("createDocument.field.\(property.name)") } } @@ -268,6 +275,7 @@ extension DocumentFieldsView { .textFieldStyle(RoundedBorderTextFieldStyle()) .autocapitalization(.none) .disableAutocorrection(true) + .accessibilityIdentifier("createDocument.field.\(property.name)") .onChange(of: currentValue) { _, newValue in // Remove any non-hex characters and convert to lowercase let cleaned = newValue.lowercased().filter { "0123456789abcdef".contains($0) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 43476c5bfad..86f85a493cb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -4,11 +4,15 @@ import SwiftDashSDK struct DocumentTypeDetailsView: View { let documentType: PersistentDocumentType + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.dismiss) var dismiss @State private var expandedIndices: Set = [] + @State private var showingCreateDocument = false var body: some View { List { + newDocumentSection documentInfoSection documentSettingsSection documentIndexesSection @@ -16,6 +20,33 @@ struct DocumentTypeDetailsView: View { } .navigationTitle(documentType.name) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingCreateDocument = true + } label: { + Image(systemName: "plus") + } + .accessibilityIdentifier("documentType.newDocumentButton") + } + } + .sheet(isPresented: $showingCreateDocument) { + CreateDocumentView(presetDocumentType: documentType) + .environmentObject(appState) + .environmentObject(walletManager) + } + } + + @ViewBuilder + private var newDocumentSection: some View { + Section { + Button { + showingCreateDocument = true + } label: { + Label("New Document", systemImage: "doc.badge.plus") + } + .accessibilityIdentifier("documentType.newDocumentRow") + } } // MARK: - Section Views diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index 7977fb2c3ea..f438775a400 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -4,6 +4,7 @@ import SwiftDashSDK struct DocumentsView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext @Query(sort: \PersistentDocument.createdAt, order: .reverse) private var documents: [PersistentDocument] @@ -43,6 +44,7 @@ struct DocumentsView: View { .sheet(isPresented: $showingCreateDocument) { CreateDocumentView() .environmentObject(appState) + .environmentObject(walletManager) } .sheet(item: $selectedDocument) { document in DocumentDetailView(document: document) @@ -156,8 +158,33 @@ struct DocumentDetailView: View { } } +/// Production "Create Document" flow. +/// +/// Renders the document type's schema fields (via `DocumentFieldsView`), +/// picks an owner identity, and broadcasts a real document state +/// transition through `ManagedPlatformWallet.createDocument(...)` — which +/// routes to `platform_wallet_create_document_with_signer` and the +/// `platform-wallet` library's `create_document_with_signer`. The +/// signing key is selected and used entirely on the Rust side via the +/// wallet's keychain-backed `KeychainSigner`; this view only collects +/// values, marshals them to a properties JSON string, calls the wrapper, +/// and persists the confirmed `PersistentDocument`. +/// +/// This is distinct from the Settings builder/test-signer path +/// (`documentCreate(...)` in `StateTransitionExtensions`). +/// +/// Launchable two ways: +/// - From `DocumentTypeDetailsView` with `presetDocumentType` set +/// (contract + type fixed, schema already in scope). +/// - From the Documents tab "+" with no preset (the user picks a +/// contract + document type first). struct CreateDocumentView: View { + /// When set, the contract + document type are fixed to this row and + /// the pickers are hidden. When nil, the user selects them. + let presetDocumentType: PersistentDocumentType? + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) var dismiss @@ -165,127 +192,358 @@ struct CreateDocumentView: View { @Query private var identities: [PersistentIdentity] @State private var selectedContract: PersistentDataContract? - @State private var selectedDocumentType = "" + @State private var selectedDocumentTypeName = "" + /// Owner identity id (base58). Drives the AccessiblePicker selection. @State private var selectedOwnerId: String = "" - @State private var dataKeyToAdd = "" - @State private var dataValueToAdd = "" - @State private var documentData: [String: String] = [:] - @State private var isLoading = false + + /// Field values produced by `DocumentFieldsView`. Byte-array fields + /// arrive as `Data`, identifier fields as `Data`, scalars as + /// `Int`/`Double`/`Bool`/`String`, arrays as `[String]`. + @State private var fieldValues: [String: Any] = [:] + + @State private var isSubmitting = false + @State private var submitError: SubmitError? + @State private var didComplete = false + @State private var createdDocumentId: String? + + init(presetDocumentType: PersistentDocumentType? = nil) { + self.presetDocumentType = presetDocumentType + } + + private struct SubmitError: Identifiable { + let id = UUID() + let message: String + } var body: some View { - NavigationView { + NavigationStack { Form { - Section(header: Text("Document Configuration")) { - Picker("Contract", selection: $selectedContract) { - Text("Select a contract").tag(nil as PersistentDataContract?) - ForEach(contracts) { contract in - Text(contract.name).tag(contract as PersistentDataContract?) - } + if didComplete { + successSection + } else { + if presetDocumentType == nil { + selectionSection + } else { + presetSection } - - if let contract = selectedContract { - Picker("Document Type", selection: $selectedDocumentType) { - Text("Select type").tag("") - ForEach(contract.documentTypesList, id: \.self) { type in - Text(type).tag(type) - } - } + ownerSection + if let docType = resolvedDocumentType { + schemaSection(for: docType) } + submitSection + } + } + .navigationTitle("New Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + } + .alert(item: $submitError) { err in + Alert( + title: Text("Create failed"), + message: Text(err.message), + dismissButton: .default(Text("OK")) + ) + } + .onAppear { + if let preset = presetDocumentType { + selectedContract = preset.dataContract + selectedDocumentTypeName = preset.name + } + } + } + } - Picker("Owner", selection: $selectedOwnerId) { - Text("Select owner").tag("") - ForEach(identities) { identity in - Text(identity.alias ?? identity.identityIdBase58) - .tag(identity.identityIdBase58) - } - } + // MARK: - Sections + + private var selectionSection: some View { + Section("Document") { + Picker("Contract", selection: $selectedContract) { + Text("Select a contract").tag(nil as PersistentDataContract?) + ForEach(contracts) { contract in + Text(contract.name).tag(contract as PersistentDataContract?) } + } + .accessibleFormPicker("createDocument.contractPicker") + .disabled(isSubmitting) - Section("Document Data") { - ForEach(Array(documentData.keys), id: \.self) { key in - HStack { - Text(key) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(documentData[key] ?? "") - .font(.subheadline) - } + if let contract = selectedContract { + Picker("Document Type", selection: $selectedDocumentTypeName) { + Text("Select type").tag("") + ForEach(documentTypeNames(for: contract), id: \.self) { type in + Text(type) + .tag(type) + .accessibilityIdentifier("createDocument.docType.\(type)") } + } + .accessibleFormPicker("createDocument.docTypePicker") + .disabled(isSubmitting) + } + } + } - HStack { - TextField("Key", text: $dataKeyToAdd) - .textFieldStyle(RoundedBorderTextFieldStyle()) - TextField("Value", text: $dataValueToAdd) - .textFieldStyle(RoundedBorderTextFieldStyle()) - Button("Add") { - if !dataKeyToAdd.isEmpty && !dataValueToAdd.isEmpty { - documentData[dataKeyToAdd] = dataValueToAdd - dataKeyToAdd = "" - dataValueToAdd = "" - } - } - } + private var presetSection: some View { + Section("Document") { + if let docType = presetDocumentType { + HStack { + Label("Contract", systemImage: "doc.plaintext") + Spacer() + Text(docType.dataContract?.name ?? docType.contractIdBase58) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + HStack { + Label("Document Type", systemImage: "doc.text") + Spacer() + Text(docType.name) + .foregroundColor(.secondary) } } - .navigationTitle("Create Document") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() + } + } + + private var ownerSection: some View { + Section { + Picker("Owner", selection: $selectedOwnerId) { + Text("Select owner").tag("") + ForEach(ownerIdentities) { identity in + Text(identity.alias ?? identity.identityIdBase58) + .tag(identity.identityIdBase58) + .accessibilityIdentifier("createDocument.owner.\(identity.identityIdBase58)") + } + } + .accessibleFormPicker("createDocument.ownerPicker") + .disabled(isSubmitting) + } header: { + Text("Owner Identity") + } footer: { + Text("The identity that owns and signs for this document. Signing uses this wallet's keychain-backed signer.") + } + } + + @ViewBuilder + private func schemaSection(for docType: PersistentDocumentType) -> some View { + Section { + DocumentFieldsView(documentType: docType, fieldValues: $fieldValues) + } header: { + Text("Fields") + } footer: { + if let required = docType.requiredFields, !required.isEmpty { + Text("Required: \(required.joined(separator: ", "))") + } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView() + .controlSize(.small) + Text("Broadcasting…") + } else { + Text("Create / Broadcast") } } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Create") { - Task { - await createDocument() - dismiss() - } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("createDocument.submitButton") + .disabled(!canSubmit || isSubmitting) + } + } + + private var successSection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Document created", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + if let id = createdDocumentId { + HStack(alignment: .top) { + Text("ID:") + .foregroundColor(.secondary) + Text(id) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) } - .disabled(selectedContract == nil || - selectedDocumentType.isEmpty || - selectedOwnerId.isEmpty || - isLoading) } + Button { + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("createDocument.doneButton") + .padding(.top, 4) } } } - private func createDocument() async { - guard appState.sdk != nil, - let contract = selectedContract, - !selectedDocumentType.isEmpty else { - appState.showError(message: "Please select a contract and document type") + // MARK: - Derived state + + /// The `PersistentDocumentType` row backing the schema form — either + /// the preset, or the one matching the selected contract + type name. + private var resolvedDocumentType: PersistentDocumentType? { + if let preset = presetDocumentType { return preset } + guard let contract = selectedContract, !selectedDocumentTypeName.isEmpty else { + return nil + } + return contract.documentTypes?.first { $0.name == selectedDocumentTypeName } + } + + /// Owner identities limited to the active network and to wallets the + /// app actually holds (so a `KeychainSigner` exists for signing). + private var ownerIdentities: [PersistentIdentity] { + identities.filter { $0.network == appState.currentNetwork && $0.wallet != nil } + } + + private var selectedOwnerIdentity: PersistentIdentity? { + ownerIdentities.first { $0.identityIdBase58 == selectedOwnerId } + } + + private var managedWallet: ManagedPlatformWallet? { + guard let walletId = selectedOwnerIdentity?.wallet?.walletId else { return nil } + return walletManager.wallet(for: walletId) + } + + private var canSubmit: Bool { + resolvedDocumentType != nil + && selectedOwnerIdentity != nil + && managedWallet != nil + } + + private func documentTypeNames(for contract: PersistentDataContract) -> [String] { + // Prefer the parsed PersistentDocumentType rows (they carry the + // schema the form needs); fall back to the stored name list. + if let types = contract.documentTypes, !types.isEmpty { + return types.map { $0.name }.sorted() + } + return contract.documentTypesList.sorted() + } + + // MARK: - Submit + + private func submit() { + guard + let docType = resolvedDocumentType, + let ownerIdentity = selectedOwnerIdentity, + let wallet = managedWallet + else { + submitError = .init(message: "Select a document type and an owner identity held by a loaded wallet.") + return + } + + let propertiesJSON: String + do { + propertiesJSON = try Self.propertiesJSON(from: fieldValues) + } catch { + submitError = .init(message: "Could not encode document fields: \(error.localizedDescription)") return } - isLoading = true - defer { isLoading = false } + isSubmitting = true + // Fresh `KeychainSigner` per submit pass, same as + // `TransferCreditsView` / `RegisterNameView`: the trampoline + // derives the signing key on demand — no bytes leave Rust. + let signer = KeychainSigner(modelContainer: modelContext.container) + let ownerId = ownerIdentity.identityId + let contractId = docType.contractId + let typeName = docType.name + let network = appState.currentNetwork + let parentContract = docType.dataContract - // Local-only create for demonstration. In the real flow we - // would broadcast the document through the SDK and wait for - // the platform acknowledgement. - let dataBlob = (try? JSONSerialization.data( - withJSONObject: documentData, - options: [] - )) ?? Data() + Task { + do { + let documentId = try await wallet.createDocument( + ownerIdentityId: ownerId, + contractId: contractId, + documentType: typeName, + propertiesJSON: propertiesJSON, + signer: signer + ) + _ = signer + await MainActor.run { + persistConfirmedDocument( + documentId: documentId, + documentType: typeName, + contractId: contractId, + ownerId: ownerId, + propertiesJSON: propertiesJSON, + network: network, + parentContract: parentContract + ) + self.createdDocumentId = documentId.toBase58String() + self.isSubmitting = false + self.didComplete = true + } + } catch { + await MainActor.run { + self.submitError = .init(message: error.localizedDescription) + self.isSubmitting = false + } + } + } + } + /// Persist the confirmed document so it shows up in the Documents + /// list (DOC-01). Persistence stays in Swift per + /// `swift-sdk/CLAUDE.md`; the broadcast itself happened in Rust. + private func persistConfirmedDocument( + documentId: Identifier, + documentType: String, + contractId: Data, + ownerId: Identifier, + propertiesJSON: String, + network: Network, + parentContract: PersistentDataContract? + ) { + let dataBlob = propertiesJSON.data(using: .utf8) ?? Data() let document = PersistentDocument( - documentId: UUID().uuidString, - documentType: selectedDocumentType, + documentId: documentId.toBase58String(), + documentType: documentType, revision: 1, data: dataBlob, - contractId: contract.idBase58, - ownerId: selectedOwnerId, - network: appState.currentNetwork + contractId: contractId.toBase58String(), + ownerId: ownerId.toBase58String(), + network: network ) - // Link to the parent contract so cascading cleanup works. - document.dataContract = contract + // Link to the parent contract so cascading cleanup works and the + // contract-scoped document list picks it up. + document.dataContract = parentContract modelContext.insert(document) + document.linkToLocalIdentityIfNeeded(in: modelContext) try? modelContext.save() + } - appState.showError(message: "Document created locally") + // MARK: - Properties JSON + + /// Convert the form's `[String: Any]` into a JSON object string the + /// Rust side can parse. `Data` values (byte arrays + identifiers) + /// are encoded as hex strings — the schema-driven sanitize step in + /// `create_document_with_signer` decodes hex/base64 byte arrays and + /// hex/base58 identifiers back to native values. Other values are + /// JSON-native and pass through unchanged. + static func propertiesJSON(from fieldValues: [String: Any]) throws -> String { + var jsonObject: [String: Any] = [:] + for (key, value) in fieldValues { + if let data = value as? Data { + jsonObject[key] = data.toHexString() + } else { + jsonObject[key] = value + } + } + let data = try JSONSerialization.data(withJSONObject: jsonObject, options: []) + return String(data: data, encoding: .utf8) ?? "{}" } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LocalDataContractsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LocalDataContractsView.swift index e99594626af..2381806035e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LocalDataContractsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LocalDataContractsView.swift @@ -11,6 +11,10 @@ struct LocalDataContractsView: View { @State private var isLoading = false @State private var errorMessage: String? @State private var showError = false + /// Contract whose details sheet is presented. Driven from the row + /// tap and presented at this stable `List` level (not per-row) so + /// the sheet survives `@Query` re-renders. + @State private var selectedContract: PersistentDataContract? @Environment(\.modelContext) private var modelContext @@ -37,7 +41,9 @@ struct LocalDataContractsView: View { .listRowInsets(EdgeInsets()) } else { ForEach(dataContracts) { contract in - DataContractRow(contract: contract) + DataContractRow(contract: contract) { + selectedContract = contract + } } .onDelete(perform: deleteContracts) } @@ -52,6 +58,9 @@ struct LocalDataContractsView: View { .disabled(isLoading) } } + .sheet(item: $selectedContract) { contract in + DataContractDetailsView(contract: contract) + } .sheet(isPresented: $showingLoadContract) { LoadDataContractView(isLoading: $isLoading) .environmentObject(platformState) @@ -80,7 +89,17 @@ struct LocalDataContractsView: View { struct DataContractRow: View { let contract: PersistentDataContract - @State private var showingDetails = false + /// Tap handler supplied by the parent list. The details sheet is + /// presented from the *stable* list container (see + /// `ContractsTabView` / `LocalDataContractsView`), NOT from inside + /// this row. A `.sheet` owned by a row lives inside the list's + /// `ForEach`; when the `@Query` re-runs (continuous SwiftData + /// writes from sync invalidate it) the row struct is recreated / + /// reordered, tearing the sheet's content — and any view pushed + /// inside it — down. Hoisting the sheet to the stable parent keeps + /// the presentation (and a deep drill-down like the document-create + /// flow) alive across list re-renders. + var onTap: () -> Void = {} var displayName: String { // Check if this is a token-only contract @@ -102,7 +121,7 @@ struct DataContractRow: View { } var body: some View { - Button(action: { showingDetails = true }) { + Button(action: onTap) { VStack(alignment: .leading, spacing: 4) { HStack { Text(displayName) @@ -127,7 +146,15 @@ struct DataContractRow: View { Spacer() - Text("Last used: \(contract.lastAccessedAt, style: .relative)") + // Statically-computed relative string. A + // `Text(date, style: .relative)` here re-renders on + // a sub-minute cadence (~1s for "N seconds ago"), + // which churns the whole `@Query` contracts list + // every second — re-creating any pushed + // `DataContractDetailsView` and popping its + // drill-downs (e.g. the document-create flow). A + // plain string does not auto-refresh. + Text("Last used: \(AppDate.relative(contract.lastAccessedAt))") .font(.caption2) .foregroundColor(.secondary) } @@ -135,9 +162,6 @@ struct DataContractRow: View { .padding(.vertical, 4) } .buttonStyle(PlainButtonStyle()) - .sheet(isPresented: $showingDetails) { - DataContractDetailsView(contract: contract) - } } } diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index ecbc47e63cc..27c7508ab95 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -16,7 +16,7 @@ Every catalog row carries four orthogonal, machine-filterable fields. Select tes - **Tier** ∈ `Essential` · `Common` · `Thorough` · `Uncommon` · `Manual` - **Layer** ∈ `Core` · `Platform` · `Cross` · `Shielded` -- **Status** ∈ `✅` · `🧪` · `⚠️` · `🔌` · `🚫` +- **Status** ∈ `✅` · `🧪` · `⚠️` · `🔌` · `🚫` · `➖` - **Category** ∈ `Core` · `Identity` · `Address` · `DPNS` · `Voting` · `Contract` · `Document` · `Token` · `Shielded` · `DashPay` · `Group` · `System` · `MultiWallet` (the feature area; shown as `Domain=…` on each §4 section header — "Category" and "Domain" are the same axis) A test is **automatable now** only if Status is `✅`, `🧪`, or `⚠️` (reachable and drivable in the simulator) **and** `Tier ≠ Manual`. `Tier=Manual` marks implemented features that need a human on a physical device (e.g. a camera) — the automated QA agent must **skip and flag them for manual testing**, never mark them failed. `🔌`/`🚫` rows are listed for completeness — skip them unless asked to confirm absence. @@ -95,6 +95,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | ⚠️ | UI exists but is **local-only / mock** — does not broadcast. | Partially (UI only) | | 🔌 | FFI and/or Swift wrapper exists, but **no UI** to trigger it. | No (SDK only) | | 🚫 | Not implemented anywhere (no FFI, no UI). | No | +| ➖ | Retired — the thing this row tracked was removed or folded into another row. | n/a | > **Entry-point reality check.** A set of Platform write transitions (identity credit withdrawal, document create/replace/delete/transfer/price/purchase, data-contract create/update, identity key-disable) are reachable in the app **only through `Settings → Platform State Transitions` → `TransitionDetailView`** (marked 🧪). They broadcast for real, but there is no per-identity "happy path" button for them. The QA agent must navigate to the builder for those rows. (Identity credit *transfer*, `ID-04`, now has a production button in `IdentityDetailView` — see that row.) The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). @@ -204,14 +205,14 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | ID | Action | Layer | Tier | Status | Entry point & test notes | |---|---|---|---|---|---| | DOC-01 | Query documents / single document | Platform | Common | ✅ | `DocumentsView` / `PlatformQueriesView` → `dash_sdk_document_search` / `_fetch`. | -| DOC-02 | Create document (broadcast) | Platform | Common | 🧪 | *Settings builder → Document Create* → `dash_sdk_document_create` (put_to_platform). NB `DOC-09` is the local mock. | +| DOC-02 | Create document (broadcast) | Platform | Common | ✅ | Production UI: Contracts → contract → document type → **New Document** (`DocumentTypeDetailsView` / schema-driven `CreateDocumentView`) → `platform_wallet_create_document_with_signer` (routes through `rs-platform-wallet` `IdentityWallet::create_document_with_signer` → SDK `put_to_platform_and_wait_for_response`, signed by the wallet's keychain signer). Driven end-to-end: created a `preorder` doc (`saltedDomainHash`) on `GWRSAV…S31Ec` from funded idx1 — network-confirmed, doc id `7i1hJgvVt8fJms26kGwkEZ6jVZxrfd3BrqfmAfpqXMoG`, persisted & appears in the documents list. *(Settings builder → Document Create / `dash_sdk_document_create` remains as a test-signer alternative.)* | | DOC-03 | Replace document | Platform | Thorough | 🧪 | *Settings builder* → `dash_sdk_document_replace_on_platform`. | | DOC-04 | Delete document | Platform | Thorough | 🧪 | *Settings builder* → `dash_sdk_document_delete`. | | DOC-05 | Transfer document | Platform | Uncommon | 🧪 | *Settings builder* → `dash_sdk_document_transfer_to_identity`. | | DOC-06 | Update document price | Platform | Uncommon | 🧪 | *Settings builder* / `DocumentWithPriceView` → `dash_sdk_document_update_price_of_document`. | | DOC-07 | Purchase document | Platform | Uncommon | 🧪 | *Settings builder* → `dash_sdk_document_purchase`. | | DOC-08 | Document count / sum / average aggregation | Platform | Uncommon | 🔌 | FFI `dash_sdk_document_count` / `_sum` / `_average`; no UI. | -| DOC-09 | Create document (local demo) | Platform | — | ⚠️ | `DocumentsView` create is local-only mock (no broadcast). Use `DOC-02` for a real write. | +| DOC-09 | Create document (local demo) | Platform | — | ➖ | Retired. The old `DocumentsView` local-only mock was replaced by the real broadcast flow (`CreateDocumentView`); see `DOC-02`. | ### 4.8 Tokens — `Domain=Token` From 92a5ce4f98d13fe9a2955f6cf7c9dbc06300624a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 15 Jun 2026 22:42:59 +0100 Subject: [PATCH 2/5] fix(swift-sdk): address CodeRabbit review on create-document flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ManagedPlatformWallet.createDocument: pin the signer with withExtendedLifetime(signer) across the FFI call instead of the unreliable `_ = signer` discard (optimizer can elide it in -O → use-after-free), matching the other *_with_signer wrappers. - DocumentFieldsView: add accessibilityLabel(property.name) to the boolean Toggle (labelsHidden() hid it from VoiceOver). - CreateDocumentView: scope the contract picker to the active network (activeContracts) so an owner can't be paired with an off-network contract; reset field state when the selected document type changes (.id + onChange) so the no-preset flow can't submit stale values. - propertiesJSON: schema-aware — decode object-typed fields from their editor JSON string into nested objects instead of double-encoding them as JSON strings. - persistConfirmedDocument: propagate modelContext.save() errors instead of swallowing with try?; on local-save failure still report the confirmed broadcast (doc is on-chain) but surface a warning. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ManagedPlatformWallet.swift | 37 +++++---- .../Views/DocumentFieldsView.swift | 1 + .../SwiftExampleApp/Views/DocumentsView.swift | 78 +++++++++++++++---- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 6ec685d3ab5..cc0ea88bfff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2611,23 +2611,30 @@ extension ManagedPlatformWallet { // `block_on_worker`, so the `withCString` scopes here are // sufficient — the pointers don't need to outlive the // call. - _ = signer var documentIdBytes = [UInt8](repeating: 0, count: 32) - let result = ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in - contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in - documentType.withCString { typePtr in - propertiesJSON.withCString { propsPtr in - documentIdBytes.withUnsafeMutableBufferPointer { outBp in - platform_wallet_create_document_with_signer( - handle, - ownerBp.baseAddress!, - contractBp.baseAddress!, - typePtr, - propsPtr, - signerHandle, - outBp.baseAddress! - ) + // Pin `signer` for the whole FFI call. A bare `_ = signer` is + // unreliable folklore — the optimizer may elide it in -O and + // release the signer before Rust dereferences `signerHandle` + // (especially in a detached task), causing a use-after-free. + // `withExtendedLifetime` guarantees it, matching the other + // `*_with_signer` wrappers in this file. + let result = withExtendedLifetime(signer) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + propertiesJSON.withCString { propsPtr in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_create_document_with_signer( + handle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + propsPtr, + signerHandle, + outBp.baseAddress! + ) + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index 6d12757d08d..d7a25210c81 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -79,6 +79,7 @@ struct DocumentFieldsView: View { Text("") } .labelsHidden() + .accessibilityLabel(property.name) .accessibilityIdentifier("createDocument.field.\(property.name)") case "array": diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index f438775a400..6ac6647261d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -205,6 +205,9 @@ struct CreateDocumentView: View { @State private var submitError: SubmitError? @State private var didComplete = false @State private var createdDocumentId: String? + /// Set when the broadcast succeeded but writing the local SwiftData + /// row failed — the document is on-chain, just not cached locally yet. + @State private var persistWarning: String? init(presetDocumentType: PersistentDocumentType? = nil) { self.presetDocumentType = presetDocumentType @@ -263,7 +266,7 @@ struct CreateDocumentView: View { Section("Document") { Picker("Contract", selection: $selectedContract) { Text("Select a contract").tag(nil as PersistentDataContract?) - ForEach(contracts) { contract in + ForEach(activeContracts) { contract in Text(contract.name).tag(contract as PersistentDataContract?) } } @@ -329,6 +332,13 @@ struct CreateDocumentView: View { private func schemaSection(for docType: PersistentDocumentType) -> some View { Section { DocumentFieldsView(documentType: docType, fieldValues: $fieldValues) + // Re-identify per document type so the field editors reset, + // and clear the parent values — otherwise switching type in + // the no-preset flow could submit the previous schema's values. + .id(docType.id) + .onChange(of: docType.id) { _, _ in + fieldValues = [:] + } } header: { Text("Fields") } footer: { @@ -376,6 +386,11 @@ struct CreateDocumentView: View { .truncationMode(.middle) } } + if let warning = persistWarning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + } Button { dismiss() } label: { @@ -407,6 +422,13 @@ struct CreateDocumentView: View { identities.filter { $0.network == appState.currentNetwork && $0.wallet != nil } } + /// Contracts limited to the active network — pairing a current-network + /// owner with a contract from another network would fetch/broadcast + /// against the wrong SDK network. + private var activeContracts: [PersistentDataContract] { + contracts.filter { $0.network == appState.currentNetwork } + } + private var selectedOwnerIdentity: PersistentIdentity? { ownerIdentities.first { $0.identityIdBase58 == selectedOwnerId } } @@ -445,7 +467,7 @@ struct CreateDocumentView: View { let propertiesJSON: String do { - propertiesJSON = try Self.propertiesJSON(from: fieldValues) + propertiesJSON = try Self.propertiesJSON(from: fieldValues, documentType: docType) } catch { submitError = .init(message: "Could not encode document fields: \(error.localizedDescription)") return @@ -473,15 +495,24 @@ struct CreateDocumentView: View { ) _ = signer await MainActor.run { - persistConfirmedDocument( - documentId: documentId, - documentType: typeName, - contractId: contractId, - ownerId: ownerId, - propertiesJSON: propertiesJSON, - network: network, - parentContract: parentContract - ) + // The broadcast is confirmed on-chain at this point. + // Persisting the local cache row is best-effort: if it + // fails we still report success (the document exists and + // is queryable) but flag the local-save failure rather + // than swallowing it. + do { + try persistConfirmedDocument( + documentId: documentId, + documentType: typeName, + contractId: contractId, + ownerId: ownerId, + propertiesJSON: propertiesJSON, + network: network, + parentContract: parentContract + ) + } catch { + self.persistWarning = "Broadcast confirmed, but saving the local copy failed: \(error.localizedDescription). The document is on-chain and queryable." + } self.createdDocumentId = documentId.toBase58String() self.isSubmitting = false self.didComplete = true @@ -506,7 +537,7 @@ struct CreateDocumentView: View { propertiesJSON: String, network: Network, parentContract: PersistentDataContract? - ) { + ) throws { let dataBlob = propertiesJSON.data(using: .utf8) ?? Data() let document = PersistentDocument( documentId: documentId.toBase58String(), @@ -522,7 +553,7 @@ struct CreateDocumentView: View { document.dataContract = parentContract modelContext.insert(document) document.linkToLocalIdentityIfNeeded(in: modelContext) - try? modelContext.save() + try modelContext.save() } // MARK: - Properties JSON @@ -531,13 +562,28 @@ struct CreateDocumentView: View { /// Rust side can parse. `Data` values (byte arrays + identifiers) /// are encoded as hex strings — the schema-driven sanitize step in /// `create_document_with_signer` decodes hex/base64 byte arrays and - /// hex/base58 identifiers back to native values. Other values are - /// JSON-native and pass through unchanged. - static func propertiesJSON(from fieldValues: [String: Any]) throws -> String { + /// hex/base58 identifiers back to native values. `object`-typed + /// fields arrive as the editor's raw JSON `String`; they are parsed + /// back into a nested object so they serialize as objects, not as a + /// JSON string. Other values are JSON-native and pass through. + static func propertiesJSON( + from fieldValues: [String: Any], + documentType: PersistentDocumentType + ) throws -> String { + let objectFields = Set( + documentType.propertiesList? + .filter { $0.type == "object" } + .map(\.name) ?? [] + ) var jsonObject: [String: Any] = [:] for (key, value) in fieldValues { if let data = value as? Data { jsonObject[key] = data.toHexString() + } else if objectFields.contains(key), let text = value as? String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let objData = trimmed.data(using: .utf8) else { continue } + // Throws on invalid JSON → surfaced as an encode error. + jsonObject[key] = try JSONSerialization.jsonObject(with: objData) } else { jsonObject[key] = value } From ae6ec6287606af09ef76d6ce57bae067de3f3e48 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 15 Jun 2026 23:10:27 +0100 Subject: [PATCH 3/5] fix(swift-sdk): address create-document review nitpicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentFieldsView: don't broadcast untouched optional booleans as `false`. The seed gives booleans no "empty" state, so they were always serialized; now only required booleans or ones the user actually toggled (tracked via `touchedBoolFields`) are included, keeping untouched optional booleans absent (absence ≠ false for some schemas). - DocumentsView.persistConfirmedDocument: on a failed `modelContext.save()`, delete the inserted row before rethrowing so a later save elsewhere can't silently flush it — matching the "not saved locally" warning. - DocumentsView selection: clear `selectedDocumentTypeName` when the contract changes so the document-type picker can't show a stale selection (or silently resolve a same-named type on another contract). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/DocumentFieldsView.swift | 32 +++++++++++++++++-- .../SwiftExampleApp/Views/DocumentsView.swift | 15 ++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index d7a25210c81..49a2022a8b3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -10,6 +10,10 @@ struct DocumentFieldsView: View { @State private var numberFields: [String: String] = [:] @State private var boolFields: [String: Bool] = [:] @State private var arrayFields: [String: String] = [:] + /// Boolean fields the user has actually toggled. Untouched optional + /// booleans are omitted from the payload (absence ≠ `false` for some + /// schemas) rather than broadcast as the seeded `false` default. + @State private var touchedBoolFields: Set = [] var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -75,7 +79,7 @@ struct DocumentFieldsView: View { .accessibilityIdentifier("createDocument.field.\(property.name)") case "boolean": - Toggle(isOn: binding(for: property.name, in: $boolFields)) { + Toggle(isOn: boolBinding(for: property.name)) { Text("") } .labelsHidden() @@ -145,6 +149,19 @@ struct DocumentFieldsView: View { return placeholder } + /// Boolean binding that records a user toggle, so untouched optional + /// booleans can be omitted from the payload (see `touchedBoolFields`). + private func boolBinding(for key: String) -> Binding { + Binding( + get: { boolFields[key] ?? false }, + set: { + boolFields[key] = $0 + touchedBoolFields.insert(key) + updateFieldValues() + } + ) + } + private func binding(for key: String, in dictionary: Binding<[String: T]>) -> Binding where T: DefaultInitializable { Binding( get: { dictionary.wrappedValue[key] ?? T() }, @@ -232,9 +249,18 @@ struct DocumentFieldsView: View { } } - // Add boolean fields + // Add boolean fields. Unlike the other types (which skip empty + // input), a seeded boolean has no "empty" state — so only include + // it if it's required or the user actually toggled it. This keeps + // untouched optional booleans absent from the payload instead of + // broadcasting the seeded `false` (absence ≠ `false` for some + // schemas). for (key, value) in boolFields { - values[key] = value + let isRequired = documentType.propertiesList? + .first(where: { $0.name == key })?.isRequired ?? false + if isRequired || touchedBoolFields.contains(key) { + values[key] = value + } } // Add array fields diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index 6ac6647261d..b696354b1cd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -272,6 +272,11 @@ struct CreateDocumentView: View { } .accessibleFormPicker("createDocument.contractPicker") .disabled(isSubmitting) + .onChange(of: selectedContract) { _, _ in + // A new contract may not have the previously-selected type + // (or could share a name) — clear so the picker isn't stale. + selectedDocumentTypeName = "" + } if let contract = selectedContract { Picker("Document Type", selection: $selectedDocumentTypeName) { @@ -553,7 +558,15 @@ struct CreateDocumentView: View { document.dataContract = parentContract modelContext.insert(document) document.linkToLocalIdentityIfNeeded(in: modelContext) - try modelContext.save() + do { + try modelContext.save() + } catch { + // Save failed — detach the row we just inserted so a later + // save from elsewhere can't silently flush it, which would + // contradict the "not saved locally" warning we surface. + modelContext.delete(document) + throw error + } } // MARK: - Properties JSON From a4690746440565cd98d23c84d58142a557b97a94 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 15 Jun 2026 23:42:57 +0100 Subject: [PATCH 4/5] feat(swift-sdk): persist canonical confirmed document, not form JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_create_document_with_signer now also returns the confirmed Document serialized to canonical JSON (DPP to_json_with_identifiers_using_bytes — $id/$ownerId as base58 + normalized properties), via a new out_document_json C string freed with platform_wallet_string_free. The Swift wrapper returns (Identifier, String) and persists the canonical JSON into PersistentDocument.data instead of the user's form-built propertiesJSON (which had hex byteArrays / base58 identifiers and no system fields). The local cache row now reflects what Platform stored. Verified on testnet: created a preorder doc on GWRSAV…S31Ec from idx1; persisted body = {"$id":"AFLkAA…mnZ2","$ownerId":"BjJz…Dx91", "saltedDomainHash":[66,31,249,…]} — canonical id/owner + normalized byteArray, matching a DOC-01 query rather than the form input. Addresses the carried-forward review finding (local-cache fidelity). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rs-platform-wallet-ffi/src/document.rs | 91 +++++++++++++++---- .../ManagedPlatformWallet.swift | 26 +++++- .../SwiftExampleApp/Views/DocumentsView.swift | 12 ++- 3 files changed, 103 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 3a4cfc5fbe7..6ee97191635 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -1,11 +1,15 @@ //! FFI bindings for document create operations on `IdentityWallet`. -use std::ffi::CStr; +use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::ptr; use std::slice; -use dpp::document::DocumentV0Getters; +use dpp::document::serialization_traits::DocumentJsonMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::Identifier; +use dpp::version::PlatformVersion; +use platform_wallet::PlatformWalletError; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -28,8 +32,21 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// stack overflow), and waits for the confirmed document. /// /// On success the confirmed document's 32-byte id is written to -/// `out_document_id`. The signature never crosses into Swift logic — -/// it routes back through the supplied `signer_handle` (typically +/// `out_document_id`, and a NUL-terminated, owned UTF-8 JSON string of +/// the confirmed document is written to `*out_document_json`. The JSON +/// is the canonical query-side representation — produced via DPP's +/// `Document::to_json_with_identifiers_using_bytes`, so it carries the +/// system fields (`$id`/`$ownerId`, set timestamps, `$revision`) with +/// identifiers rendered as base58 strings and only the document's +/// populated fields present (DPP-normalized + default-filled). Swift +/// persists this body verbatim so the local cache matches what a +/// DOC-01 query would return, rather than the user's form input. +/// Ownership of the JSON transfers to the caller, who MUST release it +/// with `platform_wallet_string_free`. On any error `*out_document_json` +/// is left null. +/// +/// The signature never crosses into Swift logic — it routes back +/// through the supplied `signer_handle` (typically /// `KeychainSigner.handle`); the caller retains ownership of the /// signer. /// @@ -48,11 +65,17 @@ pub unsafe extern "C" fn platform_wallet_create_document_with_signer( properties_json: *const c_char, signer_handle: *mut SignerHandle, out_document_id: *mut u8, + out_document_json: *mut *mut c_char, ) -> PlatformWalletFFIResult { check_ptr!(signer_handle); check_ptr!(document_type_name); check_ptr!(properties_json); check_ptr!(out_document_id); + check_ptr!(out_document_json); + + // Initialize the JSON out-param to null up front so every early + // error return leaves it null without per-branch bookkeeping. + *out_document_json = ptr::null_mut(); let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); @@ -67,25 +90,57 @@ pub unsafe extern "C" fn platform_wallet_create_document_with_signer( let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity_wallet = wallet.identity().clone(); - let result: Result = block_on_worker(async move { - let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); - identity_wallet - .create_document_with_signer( - &owner_id_for_async, - &contract_id_for_async, - &document_type_str, - properties_str, - signer, - ) - .await - .map(|document| document.id()) - }); + let result: Result<(Identifier, String), PlatformWalletError> = + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .create_document_with_signer( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + properties_str, + signer, + ) + .await?; + // Serialize the confirmed document to its canonical + // query-side JSON. `to_json_with_identifiers_using_bytes` + // renders `$id`/`$ownerId` (and `$creatorId`) as base58 + // strings and emits only the populated system fields — the + // shape a DOC-01 query display expects. The trait's + // `platform_version` argument is unused by the V0 impl, so + // `latest()` (the same convention the other FFI entry points + // in this crate use) is safe here. + let platform_version = PlatformVersion::latest(); + let json_value = confirmed + .to_json_with_identifiers_using_bytes(platform_version) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to convert confirmed document to JSON: {e}" + )) + })?; + let json_string = serde_json::to_string(&json_value).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to serialize confirmed document JSON: {e}" + )) + })?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); result }); let result = unwrap_option_or_return!(option); - let document_id = unwrap_result_or_return!(result); + let (document_id, document_json) = unwrap_result_or_return!(result); + + // Allocate the owned C string for the JSON body. A NUL byte inside + // the JSON would be a serializer bug, but guard against it rather + // than panicking across the FFI boundary. + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + let bytes = document_id.to_buffer(); let dst = slice::from_raw_parts_mut(out_document_id, 32); dst.copy_from_slice(&bytes); + // Transfer ownership of the JSON to the caller (freed via + // `platform_wallet_string_free`). Written last so the id out-param + // and the JSON are populated together on the success path. + *out_document_json = json_cstring.into_raw(); PlatformWalletFFIResult::ok() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index cc0ea88bfff..324e1db8cb5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2561,7 +2561,15 @@ extension ManagedPlatformWallet { /// Create + broadcast a new revision-1 document on `contractId`'s /// `documentType`, owned by `ownerIdentityId`. Returns the 32-byte - /// document id once Platform confirms the transition. + /// document id and the confirmed document's canonical query-side + /// JSON once Platform confirms the transition. + /// + /// The returned JSON is DPP's canonical representation of the + /// confirmed document (system fields `$id`/`$ownerId`/timestamps/ + /// `$revision` with identifiers as base58 strings, only populated + /// fields present) — what a DOC-01 query would return. Callers + /// persist this verbatim so the local cache matches the on-chain + /// document rather than the user's raw form input. /// /// Routes through `IdentityWallet::create_document_with_signer` /// (via `platform_wallet_create_document_with_signer`), the @@ -2594,7 +2602,7 @@ extension ManagedPlatformWallet { documentType: String, propertiesJSON: String, signer: KeychainSigner - ) async throws -> Identifier { + ) async throws -> (Identifier, String) { let handle = self.handle let signerHandle = signer.handle let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in @@ -2612,6 +2620,9 @@ extension ManagedPlatformWallet { // sufficient — the pointers don't need to outlive the // call. var documentIdBytes = [UInt8](repeating: 0, count: 32) + // Receives an owned canonical-document JSON C string on + // success; freed with `platform_wallet_string_free` below. + var documentJsonPtr: UnsafeMutablePointer? = nil // Pin `signer` for the whole FFI call. A bare `_ = signer` is // unreliable folklore — the optimizer may elide it in -O and @@ -2632,7 +2643,8 @@ extension ManagedPlatformWallet { typePtr, propsPtr, signerHandle, - outBp.baseAddress! + outBp.baseAddress!, + &documentJsonPtr ) } } @@ -2642,7 +2654,13 @@ extension ManagedPlatformWallet { } try result.check() - return Data(documentIdBytes) + // Take ownership of the JSON and release the Rust allocation. + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + // Defensive: on a successful broadcast the Rust side always + // writes the canonical JSON, but fall back to an empty + // string rather than crashing if it is unexpectedly null. + let canonicalJSON = documentJsonPtr.map { String(cString: $0) } ?? "" + return (Data(documentIdBytes), canonicalJSON) }.value } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index b696354b1cd..47f8c761cb8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -491,7 +491,7 @@ struct CreateDocumentView: View { Task { do { - let documentId = try await wallet.createDocument( + let (documentId, canonicalJSON) = try await wallet.createDocument( ownerIdentityId: ownerId, contractId: contractId, documentType: typeName, @@ -511,7 +511,7 @@ struct CreateDocumentView: View { documentType: typeName, contractId: contractId, ownerId: ownerId, - propertiesJSON: propertiesJSON, + canonicalJSON: canonicalJSON, network: network, parentContract: parentContract ) @@ -539,11 +539,15 @@ struct CreateDocumentView: View { documentType: String, contractId: Data, ownerId: Identifier, - propertiesJSON: String, + canonicalJSON: String, network: Network, parentContract: PersistentDataContract? ) throws { - let dataBlob = propertiesJSON.data(using: .utf8) ?? Data() + // Persist the confirmed document's canonical query-side JSON + // (system fields + DPP-normalized properties as returned by the + // Rust side), not the user's raw form input, so the local cache + // matches what a DOC-01 query would return. + let dataBlob = canonicalJSON.data(using: .utf8) ?? Data() let document = PersistentDocument( documentId: documentId.toBase58String(), documentType: documentType, From ac0a4f237cbeeffc53f85e5b24034044cea873d4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 15 Jun 2026 23:59:22 +0100 Subject: [PATCH 5/5] fix(swift-sdk): harden create-document review follow-ups - ManagedPlatformWallet.createDocument: a null canonical-JSON pointer after a successful broadcast is now thrown as a walletOperation error (FFI/ABI contract violation) instead of silently persisting an empty document body. Updated the signer lifetime-contract doc comment to describe withExtendedLifetime rather than the unsafe `_ = signer`. - DocumentFieldsView: re-sync fieldValues after the byte-array hex sanitizer rewrites the field (direct @State mutation bypasses the binding setter), so the submitted value reflects the cleaned hex. - CreateDocumentView: interactiveDismissDisabled(isSubmitting) so the sheet can't be swipe-dismissed during the non-idempotent broadcast. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ManagedPlatformWallet.swift | 25 ++++++++++++------- .../Views/DocumentFieldsView.swift | 4 +++ .../SwiftExampleApp/Views/DocumentsView.swift | 3 +++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 324e1db8cb5..58a9a7b07aa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2591,11 +2591,12 @@ extension ManagedPlatformWallet { /// converts them to native bytes / identifiers). Pass `"{}"` for a /// document type with no required properties. /// - /// Lifetime contract: the `signer` instance MUST stay alive for - /// the duration of the `await` (Rust holds a `passUnretained` - /// ctx pointer to the underlying `KeychainSigner`). A - /// `_ = signer` keepalive at the call site is the canonical way - /// to pin it. + /// Lifetime contract: the `signer` instance MUST stay alive for the + /// duration of the synchronous FFI call inside this async wrapper + /// (Rust holds a `passUnretained` ctx pointer to the underlying + /// `KeychainSigner`). The wrapper pins it with + /// `withExtendedLifetime(signer)` around the full marshalling chain — + /// a bare `_ = signer` is unreliable (the optimizer may elide it). public func createDocument( ownerIdentityId: Identifier, contractId: Identifier, @@ -2656,10 +2657,16 @@ extension ManagedPlatformWallet { try result.check() // Take ownership of the JSON and release the Rust allocation. defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } - // Defensive: on a successful broadcast the Rust side always - // writes the canonical JSON, but fall back to an empty - // string rather than crashing if it is unexpectedly null. - let canonicalJSON = documentJsonPtr.map { String(cString: $0) } ?? "" + // On a successful broadcast the Rust side always writes the + // canonical JSON; a null pointer here is an FFI/ABI contract + // violation. Fail loudly rather than persist an empty body as + // if it were the canonical document. + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "create_document_with_signer returned no canonical document JSON" + ) + } + let canonicalJSON = String(cString: jsonPtr) return (Data(documentIdBytes), canonicalJSON) }.value } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index 49a2022a8b3..f64283c54bf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -308,6 +308,10 @@ extension DocumentFieldsView { let cleaned = newValue.lowercased().filter { "0123456789abcdef".contains($0) } if cleaned != newValue { textFields[property.name] = cleaned + // Direct @State mutation bypasses the binding's + // setter, so re-sync the cleaned value into the + // submitted `fieldValues`. + updateFieldValues() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index 47f8c761cb8..b461d6ddbdb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -244,6 +244,9 @@ struct CreateDocumentView: View { .disabled(isSubmitting) } } + // Prevent swipe-to-dismiss while the (non-idempotent) broadcast + // is in flight, so the user can't lose the result/warning. + .interactiveDismissDisabled(isSubmitting) .alert(item: $submitError) { err in Alert( title: Text("Create failed"),