diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 6ee9719163..de5c06de93 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -144,3 +144,385 @@ pub unsafe extern "C" fn platform_wallet_create_document_with_signer( *out_document_json = json_cstring.into_raw(); PlatformWalletFFIResult::ok() } + +/// Serialize a confirmed `Document` to its canonical query-side JSON +/// string, the same representation the create path returns. +/// +/// `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. Swift +/// persists this verbatim so the local cache matches the on-chain +/// document. The trait's `platform_version` argument is unused by the +/// V0 impl, so `latest()` (the convention the other entry points in +/// this crate use) is safe here. +fn confirmed_document_to_json(document: &Document) -> Result { + let platform_version = PlatformVersion::latest(); + let json_value = document + .to_json_with_identifiers_using_bytes(platform_version) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to convert confirmed document to JSON: {e}" + )) + })?; + serde_json::to_string(&json_value).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to serialize confirmed document JSON: {e}" + )) + }) +} + +/// Replace + broadcast `document_id`'s properties on `contract_id`'s +/// `document_type_name`, owned by `owner_identity_id`, signed via the +/// external `signer_handle` with key `signing_key_id`. +/// +/// Goes through `IdentityWallet::replace_document_with_signer`, which +/// fetches the current document, applies `properties_json` (schema- +/// sanitized), bumps the revision, validates `signing_key_id` is an +/// AUTHENTICATION + ECDSA key on the owner, broadcasts on the +/// platform-wallet 8 MB worker stack, and waits for the confirmed +/// document. +/// +/// On success the confirmed document's 32-byte id is written to +/// `out_document_id`, and a NUL-terminated, owned canonical-document +/// JSON string is written to `*out_document_json` (release with +/// `platform_wallet_string_free`). On any error `*out_document_json` +/// is left null. `properties_json` is the full replacement property +/// object, same hex/base58 encoding rules as the create path. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_document_replace( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + document_id: *const u8, + properties_json: *const c_char, + signing_key_id: u32, + 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); + *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)); + let document_id_value = unwrap_result_or_return!(read_identifier(document_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 option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result<(Identifier, String), PlatformWalletError> = + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .replace_document_with_signer( + &owner_id, + &contract_id_value, + &document_type_str, + &document_id_value, + properties_str, + signing_key_id, + signer, + ) + .await?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); + result + }); + let result = unwrap_option_or_return!(option); + let (confirmed_id, document_json) = unwrap_result_or_return!(result); + + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + let bytes = confirmed_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + *out_document_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + +/// Delete + broadcast `document_id` on `contract_id`'s +/// `document_type_name`, owned by `owner_identity_id`, signed via the +/// external `signer_handle` with key `signing_key_id`. +/// +/// Goes through `IdentityWallet::delete_document_with_signer`. On +/// success the deleted document's 32-byte id is written to +/// `out_document_id`. Delete returns no document body, so there is no +/// JSON out-param — Swift removes the local row by id. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_document_delete( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + document_id: *const u8, + signing_key_id: u32, + signer_handle: *mut SignerHandle, + out_document_id: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(signer_handle); + check_ptr!(document_type_name); + 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_id_value = unwrap_result_or_return!(read_identifier(document_id)); + + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let deleted_id: Identifier = identity_wallet + .delete_document_with_signer( + &owner_id, + &contract_id_value, + &document_type_str, + &document_id_value, + signing_key_id, + signer, + ) + .await?; + Ok::<_, PlatformWalletError>(deleted_id) + }); + result + }); + let result = unwrap_option_or_return!(option); + let deleted_id = unwrap_result_or_return!(result); + + let bytes = deleted_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + PlatformWalletFFIResult::ok() +} + +/// Transfer + broadcast `document_id` on `contract_id`'s +/// `document_type_name`, from `owner_identity_id` to `recipient_id`, +/// signed via the external `signer_handle` with key `signing_key_id`. +/// +/// Goes through `IdentityWallet::transfer_document_with_signer`. On +/// success the confirmed document's 32-byte id is written to +/// `out_document_id` and its canonical JSON (now reflecting the new +/// owner) to `*out_document_json` (release with +/// `platform_wallet_string_free`). On any error `*out_document_json` +/// is left null. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_document_transfer( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + document_id: *const u8, + recipient_id: *const u8, + signing_key_id: u32, + 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!(out_document_id); + check_ptr!(out_document_json); + *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)); + let document_id_value = unwrap_result_or_return!(read_identifier(document_id)); + let recipient_id_value = unwrap_result_or_return!(read_identifier(recipient_id)); + + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result<(Identifier, String), PlatformWalletError> = + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .transfer_document_with_signer( + &owner_id, + &contract_id_value, + &document_type_str, + &document_id_value, + &recipient_id_value, + signing_key_id, + signer, + ) + .await?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); + result + }); + let result = unwrap_option_or_return!(option); + let (confirmed_id, document_json) = unwrap_result_or_return!(result); + + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + let bytes = confirmed_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + *out_document_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + +/// Set (update) the trade price of `document_id` on `contract_id`'s +/// `document_type_name`, owned by `owner_identity_id`, to `price` +/// credits — signed via the external `signer_handle` with key +/// `signing_key_id`. +/// +/// Goes through `IdentityWallet::set_document_price_with_signer`. On +/// success the confirmed document's 32-byte id is written to +/// `out_document_id` and its canonical JSON (now carrying `$price`) to +/// `*out_document_json` (release with `platform_wallet_string_free`). +/// On any error `*out_document_json` is left null. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_document_set_price( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + document_id: *const u8, + price: u64, + signing_key_id: u32, + 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!(out_document_id); + check_ptr!(out_document_json); + *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)); + let document_id_value = unwrap_result_or_return!(read_identifier(document_id)); + + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result<(Identifier, String), PlatformWalletError> = + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .set_document_price_with_signer( + &owner_id, + &contract_id_value, + &document_type_str, + &document_id_value, + price, + signing_key_id, + signer, + ) + .await?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); + result + }); + let result = unwrap_option_or_return!(option); + let (confirmed_id, document_json) = unwrap_result_or_return!(result); + + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + let bytes = confirmed_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + *out_document_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + +/// Purchase + broadcast for-sale `document_id` on `contract_id`'s +/// `document_type_name` for `price` credits, with `purchaser_id` as +/// the buyer (and new owner) — signed via the external `signer_handle` +/// with key `signing_key_id` (resolved on the purchaser). +/// +/// Goes through `IdentityWallet::purchase_document_with_signer`. On +/// success the confirmed document's 32-byte id is written to +/// `out_document_id` and its canonical JSON (now owned by the +/// purchaser) to `*out_document_json` (release with +/// `platform_wallet_string_free`). On any error `*out_document_json` +/// is left null. The buyer must differ from the current owner — the +/// caller gates against the self-buy consensus rejection. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_document_purchase( + wallet_handle: Handle, + purchaser_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + document_id: *const u8, + price: u64, + signing_key_id: u32, + 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!(out_document_id); + check_ptr!(out_document_json); + *out_document_json = ptr::null_mut(); + + let purchaser_id_value = unwrap_result_or_return!(read_identifier(purchaser_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + let document_id_value = unwrap_result_or_return!(read_identifier(document_id)); + + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result<(Identifier, String), PlatformWalletError> = + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .purchase_document_with_signer( + &purchaser_id_value, + &contract_id_value, + &document_type_str, + &document_id_value, + price, + signing_key_id, + signer, + ) + .await?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); + result + }); + let result = unwrap_option_or_return!(option); + let (confirmed_id, document_json) = unwrap_result_or_return!(result); + + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + let bytes = confirmed_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + *out_document_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index 38d48ead05..ddab0150f3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -27,6 +27,7 @@ //! post-broadcast GroveDB proof-verification recursion. use std::collections::BTreeMap; +use std::sync::Arc; use async_trait::async_trait; @@ -34,16 +35,26 @@ 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_methods::DocumentMethodsV0; use dpp::document::Document; +use dpp::document::DocumentV0Setters; +use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::{BinaryData, Value}; use dpp::prelude::{DataContract, Identifier}; use dpp::ProtocolError; +use dash_sdk::platform::documents::transitions::{ + DocumentDeleteResult, DocumentDeleteTransitionBuilder, DocumentPurchaseResult, + DocumentPurchaseTransitionBuilder, DocumentReplaceResult, DocumentReplaceTransitionBuilder, + DocumentSetPriceResult, DocumentSetPriceTransitionBuilder, DocumentTransferResult, + DocumentTransferTransitionBuilder, +}; use dash_sdk::platform::transition::put_document::PutDocument; -use dash_sdk::platform::Fetch; +use dash_sdk::platform::{DocumentQuery, Fetch}; use crate::error::PlatformWalletError; @@ -288,6 +299,443 @@ impl IdentityWallet { Ok(confirmed) } + + /// Fetch the on-chain `DataContract` for `contract_id` (wrapped in + /// an `Arc`, the shape the document-transition builders take) and + /// resolve+verify that `document_type_name` exists on it. + /// + /// Shared by the mutate-existing-document flows (replace / delete / + /// transfer / set-price / purchase) — each needs the contract as an + /// `Arc` for both the single-document fetch query and + /// the transition builder. + async fn fetch_contract_arc_for_document_op( + &self, + contract_id: &Identifier, + document_type_name: &str, + ) -> Result, PlatformWalletError> { + let data_contract = DataContract::fetch(&self.sdk, *contract_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch contract {contract_id} for document operation: {e}" + )) + })? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Data contract {contract_id} not found on Platform; cannot operate on document" + )) + })?; + // Validate the document type exists up front so the caller gets + // a clear error before a fetch/broadcast round-trip. + data_contract + .document_type_for_name(document_type_name) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Document type {document_type_name:?} not found on contract {contract_id}: {e}" + )) + })?; + Ok(Arc::new(data_contract)) + } + + /// Fetch the single current on-chain `Document` for + /// `(contract, document_type_name, document_id)`. + /// + /// The mutate flows that carry the full document into their + /// transition builder (replace / transfer / set-price / purchase) + /// need the *current* revision + base data — they clone it, bump the + /// revision, and (for replace) overwrite properties. Fetching here + /// (rather than trusting a Swift-supplied document) keeps the + /// revision authoritative and matches `rs-sdk-ffi`'s builder path, + /// which also operates on a fetched/known document. + async fn fetch_current_document( + &self, + data_contract: &Arc, + document_type_name: &str, + document_id: &Identifier, + ) -> Result { + let query = DocumentQuery::new(Arc::clone(data_contract), document_type_name) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to build document query: {e}" + )) + })? + .with_document_id(document_id); + Document::fetch(&self.sdk, query) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch document {document_id}: {e}" + )) + })? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Document {document_id} not found on Platform; cannot operate on it" + )) + }) + } + + /// Resolve the AUTHENTICATION signing key `signing_key_id` on + /// `owner_identity_id` from the in-process wallet manager. + /// + /// Unlike `create_document_with_signer` (which auto-selects an + /// AUTHENTICATION key by security level), the mutate flows take an + /// explicit `signing_key_id` chosen by the caller's key picker, so + /// the user keeps control of which key signs. We still enforce the + /// document state-transition signing rule here: the key must exist, + /// be AUTHENTICATION-purpose, and be ECDSA_SECP256K1 — the same + /// purpose `create` uses (see + /// `project_document_signing_key_purpose_bug`: signing with a + /// non-AUTHENTICATION key, e.g. a TRANSFER/CRITICAL key, is rejected + /// by consensus with "requires AUTHENTICATION"). + async fn resolve_authentication_signing_key( + &self, + owner_identity_id: &Identifier, + signing_key_id: u32, + ) -> Result { + 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))?; + let key = identity + .get_public_key_by_id(signing_key_id) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Signing key {signing_key_id} not found on identity {owner_identity_id}" + )) + })? + .clone(); + if key.purpose() != Purpose::AUTHENTICATION { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Signing key {signing_key_id} on identity {owner_identity_id} has purpose {:?}, \ + but a document state transition must be signed with an AUTHENTICATION key", + key.purpose() + ))); + } + if key.key_type() != KeyType::ECDSA_SECP256K1 { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Signing key {signing_key_id} on identity {owner_identity_id} has key type {:?}, \ + but a document state transition must be signed with an ECDSA_SECP256K1 key", + key.key_type() + ))); + } + Ok(key) + } + + /// Replace an existing document's properties on + /// `contract_id`'s `document_type_name` and broadcast. + /// + /// Fetches the current document, applies `properties_json` (parsed + /// + schema-sanitized exactly like the create path), bumps the + /// revision, signs with the explicit `signing_key_id` + /// (AUTHENTICATION + ECDSA), broadcasts via `Sdk::document_replace` + /// on the platform-wallet 8 MB worker stack, and returns the + /// confirmed `Document`. + #[allow(clippy::too_many_arguments)] + pub async fn replace_document_with_signer( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + document_id: &Identifier, + properties_json: &str, + signing_key_id: u32, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let data_contract = self + .fetch_contract_arc_for_document_op(contract_id, document_type_name) + .await?; + + // Owned `DocumentType` to sanitize the supplied properties + // against the schema — same conversion the create path runs. + 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}" + )) + })?; + + let mut document = self + .fetch_current_document(&data_contract, document_type_name, document_id) + .await?; + + // Parse + sanitize the new properties, then overwrite the + // fetched document's property map. The system fields (id, owner, + // timestamps, revision) are preserved from the fetched 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); + document.set_properties(properties); + + // Bump the revision for the replacement (mirrors the rs-sdk-ffi + // replace builder, which increments before building). + document.increment_revision().map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to increment document revision: {e}" + )) + })?; + + let signing_key = self + .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .await?; + + let builder = DocumentReplaceTransitionBuilder::new( + data_contract, + document_type_name.to_string(), + document, + ); + let DocumentReplaceResult::Document(confirmed) = self + .sdk + .document_replace(builder, &signing_key, &SignerRef(signer)) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to replace document: {e}")) + })?; + Ok(confirmed) + } + + /// Delete an existing document on `contract_id`'s + /// `document_type_name` and broadcast. + /// + /// Signs with the explicit `signing_key_id` (AUTHENTICATION + + /// ECDSA) and broadcasts via `Sdk::document_delete` on the + /// platform-wallet 8 MB worker stack. Returns the deleted document's + /// `Identifier` on confirmation. + pub async fn delete_document_with_signer( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + document_id: &Identifier, + signing_key_id: u32, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let data_contract = self + .fetch_contract_arc_for_document_op(contract_id, document_type_name) + .await?; + + let signing_key = self + .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .await?; + + // Delete is keyed by (document_id, owner_id); no current-document + // fetch is required. + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + document_type_name.to_string(), + *document_id, + *owner_identity_id, + ); + let DocumentDeleteResult::Deleted(deleted_id) = self + .sdk + .document_delete(builder, &signing_key, &SignerRef(signer)) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to delete document: {e}")) + })?; + Ok(deleted_id) + } + + /// Transfer an existing document on `contract_id`'s + /// `document_type_name` to `recipient_id` and broadcast. + /// + /// Fetches the current document, bumps the revision, signs with the + /// explicit `signing_key_id` (AUTHENTICATION + ECDSA), broadcasts + /// via `Sdk::document_transfer` on the platform-wallet 8 MB worker + /// stack, and returns the confirmed `Document` (now owned by + /// `recipient_id`). + #[allow(clippy::too_many_arguments)] + pub async fn transfer_document_with_signer( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + document_id: &Identifier, + recipient_id: &Identifier, + signing_key_id: u32, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let data_contract = self + .fetch_contract_arc_for_document_op(contract_id, document_type_name) + .await?; + + let mut document = self + .fetch_current_document(&data_contract, document_type_name, document_id) + .await?; + document.increment_revision().map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to increment document revision: {e}" + )) + })?; + + let signing_key = self + .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .await?; + + let builder = DocumentTransferTransitionBuilder::new( + data_contract, + document_type_name.to_string(), + document, + *recipient_id, + ); + let DocumentTransferResult::Document(confirmed) = self + .sdk + .document_transfer(builder, &signing_key, &SignerRef(signer)) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to transfer document: {e}" + )) + })?; + Ok(confirmed) + } + + /// Set (update) the trade price of an existing document on + /// `contract_id`'s `document_type_name` and broadcast. + /// + /// Fetches the current document, bumps the revision, signs with the + /// explicit `signing_key_id` (AUTHENTICATION + ECDSA), broadcasts + /// via `Sdk::document_set_price` on the platform-wallet 8 MB worker + /// stack, and returns the confirmed `Document` (now carrying + /// `$price`). + #[allow(clippy::too_many_arguments)] + pub async fn set_document_price_with_signer( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + document_id: &Identifier, + price: u64, + signing_key_id: u32, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let data_contract = self + .fetch_contract_arc_for_document_op(contract_id, document_type_name) + .await?; + + let mut document = self + .fetch_current_document(&data_contract, document_type_name, document_id) + .await?; + document.increment_revision().map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to increment document revision: {e}" + )) + })?; + + let signing_key = self + .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .await?; + + let builder = DocumentSetPriceTransitionBuilder::new( + data_contract, + document_type_name.to_string(), + document, + price as Credits, + ); + let DocumentSetPriceResult::Document(confirmed) = self + .sdk + .document_set_price(builder, &signing_key, &SignerRef(signer)) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to set document price: {e}" + )) + })?; + Ok(confirmed) + } + + /// Purchase an existing for-sale document on `contract_id`'s + /// `document_type_name` and broadcast. + /// + /// `purchaser_identity_id` is the buyer (which becomes the new + /// owner) and signs the transition with `signing_key_id` + /// (AUTHENTICATION + ECDSA) — so the signing key is resolved on the + /// purchaser, not the current owner. Fetches the current document, + /// bumps the revision, broadcasts via `Sdk::document_purchase` on + /// the platform-wallet 8 MB worker stack, and returns the confirmed + /// `Document` (now owned by the purchaser). Consensus rejects a + /// purchase where the buyer is the current owner — the caller's UI + /// gates against that. + #[allow(clippy::too_many_arguments)] + pub async fn purchase_document_with_signer( + &self, + purchaser_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + document_id: &Identifier, + price: u64, + signing_key_id: u32, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let data_contract = self + .fetch_contract_arc_for_document_op(contract_id, document_type_name) + .await?; + + let mut document = self + .fetch_current_document(&data_contract, document_type_name, document_id) + .await?; + document.increment_revision().map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to increment document revision: {e}" + )) + })?; + + let signing_key = self + .resolve_authentication_signing_key(purchaser_identity_id, signing_key_id) + .await?; + + let builder = DocumentPurchaseTransitionBuilder::new( + data_contract, + document_type_name.to_string(), + document, + *purchaser_identity_id, + price as Credits, + ); + let DocumentPurchaseResult::Document(confirmed) = self + .sdk + .document_purchase(builder, &signing_key, &SignerRef(signer)) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to purchase document: {e}" + )) + })?; + Ok(confirmed) + } } #[cfg(test)] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 58a9a7b07a..6ef4a0f4d2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2671,6 +2671,380 @@ extension ManagedPlatformWallet { }.value } + /// Replace + broadcast `documentId`'s properties on `contractId`'s + /// `documentType`, owned by `ownerIdentityId`, signed with the + /// explicit AUTHENTICATION + ECDSA key `signingKeyId`. Returns the + /// 32-byte document id and the confirmed document's canonical + /// query-side JSON (now at the bumped revision) once Platform + /// confirms the transition. + /// + /// Sibling to `createDocument`. Routes through + /// `IdentityWallet::replace_document_with_signer` (via + /// `platform_wallet_document_replace`): the Rust side fetches the + /// current document, applies `propertiesJSON` (the full replacement + /// property object, same hex/base58 encoding rules as create), + /// bumps the revision, validates `signingKeyId` is an + /// AUTHENTICATION + ECDSA key on the owner, broadcasts on the + /// platform-wallet 8 MB worker stack, and waits for confirmation. + /// The signing key never crosses into Swift logic — the + /// `KeychainSigner` trampoline services the signature on demand. + /// Callers persist the returned JSON verbatim so the local cache + /// matches the on-chain document. + /// + /// Lifetime contract: identical to `createDocument` — the `signer` + /// is pinned with `withExtendedLifetime` across the synchronous FFI + /// call (Rust holds a `passUnretained` ctx pointer to the + /// underlying `KeychainSigner`). + public func replaceDocument( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + documentId: Identifier, + propertiesJSON: String, + signingKeyId: UInt32, + signer: KeychainSigner + ) async throws -> (Identifier, String) { + 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)) + } + let documentBytes: [UInt8] = documentId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var documentIdBytes = [UInt8](repeating: 0, count: 32) + var documentJsonPtr: UnsafeMutablePointer? = nil + + // Pin `signer` for the whole FFI call (see `createDocument` + // for why a bare `_ = signer` is unreliable under -O). + let result = withExtendedLifetime(signer) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + documentBytes.withUnsafeBufferPointer { docBp -> PlatformWalletFFIResult in + propertiesJSON.withCString { propsPtr in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_document_replace( + handle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + docBp.baseAddress!, + propsPtr, + signingKeyId, + signerHandle, + outBp.baseAddress!, + &documentJsonPtr + ) + } + } + } + } + } + } + } + + try result.check() + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "document_replace returned no canonical document JSON" + ) + } + let canonicalJSON = String(cString: jsonPtr) + return (Data(documentIdBytes), canonicalJSON) + }.value + } + + /// Delete + broadcast `documentId` on `contractId`'s `documentType`, + /// owned by `ownerIdentityId`, signed with the explicit + /// AUTHENTICATION + ECDSA key `signingKeyId`. Returns the deleted + /// document's 32-byte id once Platform confirms the transition. + /// + /// Sibling to `createDocument`. Routes through + /// `IdentityWallet::delete_document_with_signer` (via + /// `platform_wallet_document_delete`). Delete returns no document + /// body, so there is no canonical JSON — callers remove the local + /// `PersistentDocument` row by id. + public func deleteDocument( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + documentId: Identifier, + signingKeyId: UInt32, + 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)) + } + let documentBytes: [UInt8] = documentId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var documentIdBytes = [UInt8](repeating: 0, count: 32) + + let result = withExtendedLifetime(signer) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + documentBytes.withUnsafeBufferPointer { docBp -> PlatformWalletFFIResult in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_document_delete( + handle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + docBp.baseAddress!, + signingKeyId, + signerHandle, + outBp.baseAddress! + ) + } + } + } + } + } + } + + try result.check() + return Data(documentIdBytes) + }.value + } + + /// Transfer + broadcast `documentId` on `contractId`'s + /// `documentType`, from `ownerIdentityId` to `recipientId`, signed + /// with the explicit AUTHENTICATION + ECDSA key `signingKeyId`. + /// Returns the 32-byte document id and the confirmed document's + /// canonical JSON (now reflecting the new owner) once Platform + /// confirms the transition. + /// + /// Sibling to `createDocument`. Routes through + /// `IdentityWallet::transfer_document_with_signer` (via + /// `platform_wallet_document_transfer`). Only valid for a document + /// type whose schema marks it `transferable`; the caller gates the + /// action against that flag. + public func transferDocument( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + documentId: Identifier, + recipientId: Identifier, + signingKeyId: UInt32, + signer: KeychainSigner + ) async throws -> (Identifier, String) { + 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)) + } + let documentBytes: [UInt8] = documentId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let recipientBytes: [UInt8] = recipientId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var documentIdBytes = [UInt8](repeating: 0, count: 32) + var documentJsonPtr: UnsafeMutablePointer? = nil + + let result = withExtendedLifetime(signer) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + documentBytes.withUnsafeBufferPointer { docBp -> PlatformWalletFFIResult in + recipientBytes.withUnsafeBufferPointer { recipBp -> PlatformWalletFFIResult in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_document_transfer( + handle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + docBp.baseAddress!, + recipBp.baseAddress!, + signingKeyId, + signerHandle, + outBp.baseAddress!, + &documentJsonPtr + ) + } + } + } + } + } + } + } + + try result.check() + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "document_transfer returned no canonical document JSON" + ) + } + let canonicalJSON = String(cString: jsonPtr) + return (Data(documentIdBytes), canonicalJSON) + }.value + } + + /// Set (update) the trade price of `documentId` on `contractId`'s + /// `documentType` to `price` credits, owned by `ownerIdentityId`, + /// signed with the explicit AUTHENTICATION + ECDSA key + /// `signingKeyId`. Returns the 32-byte document id and the confirmed + /// document's canonical JSON (now carrying `$price`) once Platform + /// confirms the transition. + /// + /// Sibling to `createDocument`. Routes through + /// `IdentityWallet::set_document_price_with_signer` (via + /// `platform_wallet_document_set_price`). Only valid for a document + /// type whose schema enables a trade mode; the caller gates the + /// action against that flag. + public func setDocumentPrice( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + documentId: Identifier, + price: UInt64, + signingKeyId: UInt32, + signer: KeychainSigner + ) async throws -> (Identifier, String) { + 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)) + } + let documentBytes: [UInt8] = documentId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var documentIdBytes = [UInt8](repeating: 0, count: 32) + var documentJsonPtr: UnsafeMutablePointer? = nil + + let result = withExtendedLifetime(signer) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + documentBytes.withUnsafeBufferPointer { docBp -> PlatformWalletFFIResult in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_document_set_price( + handle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + docBp.baseAddress!, + price, + signingKeyId, + signerHandle, + outBp.baseAddress!, + &documentJsonPtr + ) + } + } + } + } + } + } + + try result.check() + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "document_set_price returned no canonical document JSON" + ) + } + let canonicalJSON = String(cString: jsonPtr) + return (Data(documentIdBytes), canonicalJSON) + }.value + } + + /// Purchase + broadcast for-sale `documentId` on `contractId`'s + /// `documentType` for `price` credits, with `purchaserId` as the + /// buyer (and new owner), signed with the explicit AUTHENTICATION + + /// ECDSA key `signingKeyId` resolved on the purchaser. Returns the + /// 32-byte document id and the confirmed document's canonical JSON + /// (now owned by the purchaser) once Platform confirms the + /// transition. + /// + /// Sibling to `createDocument`. Routes through + /// `IdentityWallet::purchase_document_with_signer` (via + /// `platform_wallet_document_purchase`). Consensus rejects a + /// purchase where the buyer is the current owner — the caller's UI + /// gates against that self-buy case. + public func purchaseDocument( + purchaserId: Identifier, + contractId: Identifier, + documentType: String, + documentId: Identifier, + price: UInt64, + signingKeyId: UInt32, + signer: KeychainSigner + ) async throws -> (Identifier, String) { + let handle = self.handle + let signerHandle = signer.handle + let purchaserBytes: [UInt8] = purchaserId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let contractBytes: [UInt8] = contractId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let documentBytes: [UInt8] = documentId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var documentIdBytes = [UInt8](repeating: 0, count: 32) + var documentJsonPtr: UnsafeMutablePointer? = nil + + let result = withExtendedLifetime(signer) { + purchaserBytes.withUnsafeBufferPointer { purchaserBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + documentBytes.withUnsafeBufferPointer { docBp -> PlatformWalletFFIResult in + documentIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_document_purchase( + handle, + purchaserBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + docBp.baseAddress!, + price, + signingKeyId, + signerHandle, + outBp.baseAddress!, + &documentJsonPtr + ) + } + } + } + } + } + } + + try result.check() + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "document_purchase returned no canonical document JSON" + ) + } + let canonicalJSON = String(cString: jsonPtr) + return (Data(documentIdBytes), canonicalJSON) + }.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/Views/ContractsTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift index c106fa4633..96b4fa01ab 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift @@ -32,6 +32,12 @@ import SwiftDashSDK struct ContractsTabView: View { @EnvironmentObject var platformState: AppState @EnvironmentObject var transitionState: TransitionState + /// Needed only to forward into the presented `DocumentsView` sheet + /// (which declares it as an `@EnvironmentObject`). Reading it in this + /// leaf tab view re-renders only `ContractsTabView`, not the parent + /// `TabView`, so it doesn't reintroduce the progress-tick churn the + /// `WalletManagerStore` indirection in `ContentView` guards against. + @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext /// Active network the parent passed in. Drives both the @@ -77,6 +83,10 @@ struct ContractsTabView: View { @State private var showingLoadContract = false @State private var showingRegisterContract = false + /// Drives the documents-browser sheet. `DocumentsView` wraps its own + /// `NavigationView`, so it's presented as a sheet (not pushed) to + /// avoid nesting navigation stacks. + @State private var showingDocuments = false @State private var isLoading = false @State private var errorMessage: String? @State private var showError = false @@ -153,6 +163,15 @@ struct ContractsTabView: View { .navigationTitle("Contracts") .navigationBarTitleDisplayMode(.large) .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingDocuments = true + } label: { + Image(systemName: "doc.text.magnifyingglass") + } + .accessibilityLabel("Browse Documents") + .accessibilityIdentifier("contracts.browseDocuments") + } ToolbarItem(placement: .navigationBarTrailing) { Menu { Button { @@ -183,6 +202,20 @@ struct ContractsTabView: View { .environmentObject(transitionState) .environment(\.modelContext, modelContext) } + .sheet(isPresented: $showingDocuments) { + // `DocumentsView` brings its own `NavigationView`, so it's + // presented as a sheet (never pushed). It declares + // `AppState`, `PlatformWalletManager`, and `TransitionState` + // as `@EnvironmentObject`s — forward all three explicitly + // like the sibling sheets above. The SwiftData + // `modelContext` is inherited through the sheet, but pin it + // explicitly to match the surrounding pattern. + DocumentsView() + .environmentObject(platformState) + .environmentObject(walletManager) + .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 @@ -1040,4 +1073,5 @@ struct TokenListRow: View { ContractsTabView(network: .testnet) .environmentObject(AppState()) .environmentObject(TransitionState()) + .environmentObject(PlatformWalletManager()) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index b461d6ddbd..7afcbc6b43 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -5,12 +5,19 @@ import SwiftDashSDK struct DocumentsView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var transitionState: TransitionState @Environment(\.modelContext) private var modelContext @Query(sort: \PersistentDocument.createdAt, order: .reverse) private var documents: [PersistentDocument] @State private var showingCreateDocument = false @State private var selectedDocument: PersistentDocument? + /// Hoisted to the stable `DocumentsView` container (rather than the + /// detail sheet) so a background-sync `@Query` re-render of the + /// detail view can't tear down the action sheet mid-flow — the + /// known nav-churn self-dismiss. See + /// `reference_swiftexampleapp_nav_churn`. + @State private var documentActionMode: DocumentActionMode? var body: some View { NavigationView { @@ -47,7 +54,25 @@ struct DocumentsView: View { .environmentObject(walletManager) } .sheet(item: $selectedDocument) { document in - DocumentDetailView(document: document) + DocumentDetailView(document: document) { mode in + // Close the detail sheet, then present the action + // sheet from the stable container on the next runloop + // tick (two sheets can't be presented from the same + // anchor simultaneously). + selectedDocument = nil + DispatchQueue.main.async { + documentActionMode = mode + } + } + .environmentObject(appState) + .environmentObject(walletManager) + } + // Action sheets hoisted here so they outlive detail-view churn. + .sheet(item: $documentActionMode) { mode in + DocumentActionSheet(mode: mode) + .environmentObject(appState) + .environmentObject(walletManager) + .environmentObject(transitionState) } } } @@ -61,6 +86,35 @@ struct DocumentsView: View { } } +/// The five ownership-gated document state-transition actions, carrying +/// the document they operate on. `Identifiable` so it can drive a single +/// hoisted `sheet(item:)` on `DocumentsView`. +enum DocumentActionMode: Identifiable { + case replace(PersistentDocument) + case delete(PersistentDocument) + case transfer(PersistentDocument) + case setPrice(PersistentDocument) + case purchase(PersistentDocument) + + var id: String { + switch self { + case .replace(let d): return "replace-\(d.documentId)" + case .delete(let d): return "delete-\(d.documentId)" + case .transfer(let d): return "transfer-\(d.documentId)" + case .setPrice(let d): return "setPrice-\(d.documentId)" + case .purchase(let d): return "purchase-\(d.documentId)" + } + } + + var document: PersistentDocument { + switch self { + case .replace(let d), .delete(let d), .transfer(let d), + .setPrice(let d), .purchase(let d): + return d + } + } +} + struct DocumentRow: View { let document: PersistentDocument let onTap: () -> Void @@ -99,8 +153,21 @@ struct DocumentRow: View { struct DocumentDetailView: View { let document: PersistentDocument + /// Invoked when the user picks one of the gated actions; the parent + /// (`DocumentsView`) dismisses this detail sheet and presents the + /// action sheet from its stable container. + var onAction: (DocumentActionMode) -> Void = { _ in } + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) var dismiss + /// Identities the wallet controls on the active network (own a + /// `KeychainSigner` for signing). Used to gate the owner-only and + /// purchaser actions. + @Query private var identities: [PersistentIdentity] + var body: some View { NavigationView { ScrollView { @@ -111,6 +178,7 @@ struct DocumentDetailView: View { DetailRow(label: "Document ID", value: document.documentId) DetailRow(label: "Contract ID", value: document.contractIdBase58) DetailRow(label: "Owner ID", value: document.ownerIdBase58) + DetailRow(label: "Revision", value: "\(document.revision)") DetailRow(label: "Created", value: AppDate.formatted(document.createdAt)) DetailRow(label: "Updated", value: AppDate.formatted(document.updatedAt)) } @@ -142,8 +210,87 @@ struct DocumentDetailView: View { dismiss() } } + if !availableActions.isEmpty { + ToolbarItem(placement: .navigationBarLeading) { + actionMenu + } + } + } + } + } + + // MARK: - Action menu + gating + + @ViewBuilder + private var actionMenu: some View { + Menu { + ForEach(availableActions) { action in + Button { + onAction(action.mode(for: document)) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .accessibilityIdentifier("documentAction.\(action.rawValue)") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityIdentifier("documentAction.menu") + } + + /// The `PersistentDocumentType` backing this document — preferred via + /// the persisted relationship, falling back to a contract+name lookup + /// (older rows may predate the relationship being linked). Drives the + /// `transferable` / `tradeMode` gating. + private var documentTypeRow: PersistentDocumentType? { + if let linked = document.documentType_relation { + return linked + } + return document.dataContract?.documentTypes? + .first { $0.name == document.documentType } + } + + /// True when the document's owner is an identity this wallet controls + /// (held locally with a wallet, so a `KeychainSigner` can sign). + private var ownerIsControlled: Bool { + controlledIdentities.contains { $0.identityIdBase58 == document.ownerIdBase58 } + } + + /// Controlled identities on the active network that differ from the + /// current owner — the eligible purchasers (buyer ≠ owner). + private var nonOwnerControlledIdentities: [PersistentIdentity] { + controlledIdentities.filter { $0.identityIdBase58 != document.ownerIdBase58 } + } + + private var controlledIdentities: [PersistentIdentity] { + identities.filter { $0.network == appState.currentNetwork && $0.wallet != nil } + } + + /// Whether the on-chain document carries a `$price` is fetched + /// asynchronously inside the Purchase sheet (reusing the + /// `DocumentWithPriceView` read). For the menu we surface Purchase + /// whenever a different controlled identity exists and the doc type + /// supports a trade mode — the sheet then resolves the actual price / + /// for-sale state and disables the button if it isn't for sale. + private var availableActions: [DocumentAction] { + var actions: [DocumentAction] = [] + let docType = documentTypeRow + + if ownerIsControlled { + actions.append(.replace) + actions.append(.delete) + if docType?.documentsTransferable == true { + actions.append(.transfer) + } + if (docType?.tradeMode ?? 0) > 0 { + actions.append(.setPrice) } + } else if (docType?.tradeMode ?? 0) > 0 && !nonOwnerControlledIdentities.isEmpty { + // Not the owner, but the wallet holds another identity that + // could buy a for-sale, tradeable document. + actions.append(.purchase) } + return actions } private func formattedProperties(_ properties: [String: Any]?) -> String { @@ -158,6 +305,1083 @@ struct DocumentDetailView: View { } } +// MARK: - Document actions (replace / delete / transfer / set-price / purchase) + +/// The menu entries the gated action menu surfaces. Maps to a +/// `DocumentActionMode` once a concrete document is in hand. +enum DocumentAction: String, Identifiable, CaseIterable { + case replace + case delete + case transfer + case setPrice + case purchase + + var id: String { rawValue } + + var title: String { + switch self { + case .replace: return "Replace…" + case .delete: return "Delete…" + case .transfer: return "Transfer…" + case .setPrice: return "Set Price…" + case .purchase: return "Purchase…" + } + } + + var systemImage: String { + switch self { + case .replace: return "pencil" + case .delete: return "trash" + case .transfer: return "arrow.left.arrow.right" + case .setPrice: return "tag" + case .purchase: return "cart" + } + } + + func mode(for document: PersistentDocument) -> DocumentActionMode { + switch self { + case .replace: return .replace(document) + case .delete: return .delete(document) + case .transfer: return .transfer(document) + case .setPrice: return .setPrice(document) + case .purchase: return .purchase(document) + } + } +} + +/// Dispatches the hoisted `sheet(item:)` to the per-action editor view. +/// A thin router so `DocumentsView` only presents one sheet type. +struct DocumentActionSheet: View { + let mode: DocumentActionMode + + var body: some View { + switch mode { + case .replace(let doc): + ReplaceDocumentView(document: doc) + case .delete(let doc): + DeleteDocumentView(document: doc) + case .transfer(let doc): + TransferDocumentView(document: doc) + case .setPrice(let doc): + SetDocumentPriceView(document: doc) + case .purchase(let doc): + PurchaseDocumentView(document: doc) + } + } +} + +/// Shared select-key → sign → call-wrapper → persist pipeline behind all +/// five document mutate actions. +/// +/// Per `project_document_signing_key_purpose_bug` the signing key MUST be +/// an AUTHENTICATION key (a TRANSFER/CRITICAL key is rejected by consensus +/// with "requires AUTHENTICATION"); we resolve it via +/// `KeyManager.findSigningKey(purpose: .authentication, ...)` and pass its +/// id to the `ManagedPlatformWallet` wrapper, which re-validates it +/// Rust-side. The actual signature is produced on demand by the +/// `KeychainSigner` trampoline — no private bytes ever cross into Swift +/// logic here. +@MainActor +enum DocumentActionRunner { + /// Resolve the controlled identity + its `ManagedPlatformWallet` and + /// the AUTHENTICATION signing-key id for `identity`, satisfying the + /// document type's security-level requirement. Throws a descriptive + /// error if any precondition is missing. + static func resolveSigning( + for identity: PersistentIdentity, + documentType: PersistentDocumentType?, + walletManager: PlatformWalletManager + ) throws -> (wallet: ManagedPlatformWallet, signingKeyId: UInt32) { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw DocumentActionError.noWallet + } + + // The document type's security level bounds which AUTHENTICATION + // keys may sign (mirrors the Rust-side requirement). Fall back to + // HIGH when unknown — the same default the builder path uses. + let requiredLevel = SecurityLevel(rawValue: UInt8(documentType?.securityLevel ?? 2)) ?? .high + + let dppIdentity = DPPIdentity( + id: identity.identityId, + publicKeys: Dictionary( + uniqueKeysWithValues: identity.identityPublicKeys.map { ($0.id, $0) } + ), + balance: UInt64(bitPattern: identity.balance), + revision: 0 + ) + + let km = KeyManager.withSharedKeychain() + guard let key = km.findSigningKey( + for: dppIdentity, + purpose: .authentication, + minimumSecurityLevel: requiredLevel, + preferCritical: true + ) else { + throw DocumentActionError.noSigningKey(requiredLevel.name) + } + return (wallet, key.id) + } +} + +enum DocumentActionError: LocalizedError { + case noWallet + case noSigningKey(String) + case identityNotFound + + var errorDescription: String? { + switch self { + case .noWallet: + return "The owner identity is not held by a loaded wallet on this device." + case .noSigningKey(let level): + return "No AUTHENTICATION key with security \(level) or higher (and an available private key) found on the signing identity." + case .identityNotFound: + return "Could not resolve the controlled identity for this operation." + } + } +} + +/// Small reusable success/error footer for the action sheets. +private struct ActionStatusView: View { + let didComplete: Bool + let confirmedId: String? + let persistWarning: String? + let onDone: () -> Void + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Broadcast confirmed", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + if let id = confirmedId { + HStack(alignment: .top) { + Text("Doc ID:").foregroundColor(.secondary) + Text(id) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + } + } + if let warning = persistWarning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + } + Button { + onDone() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("documentAction.doneButton") + .padding(.top, 4) + } + } + } +} + +private struct DocumentActionErrorBox: Identifiable { + let id = UUID() + let message: String +} + +// MARK: Replace + +/// Replace the document's properties. The JSON editor is seeded from the +/// document's current properties; the full object is sent as the +/// replacement (the Rust side schema-sanitizes + bumps the revision). +struct ReplaceDocumentView: View { + let document: PersistentDocument + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) var dismiss + + @Query private var identities: [PersistentIdentity] + + @State private var propertiesText: String = "" + @State private var isSubmitting = false + @State private var didComplete = false + @State private var confirmedId: String? + @State private var persistWarning: String? + @State private var actionError: DocumentActionErrorBox? + + var body: some View { + NavigationStack { + Form { + if didComplete { + ActionStatusView( + didComplete: didComplete, + confirmedId: confirmedId, + persistWarning: persistWarning, + onDone: { dismiss() } + ) + } else { + Section { + DetailRow(label: "Document ID", value: document.documentId) + DetailRow(label: "Type", value: document.documentType) + } + Section { + TextEditor(text: $propertiesText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 200) + .disabled(isSubmitting) + .accessibilityIdentifier("documentReplace.jsonEditor") + } header: { + Text("Properties (JSON)") + } footer: { + Text("Full replacement property object. Byte-array fields as hex, identifier fields as base58.") + } + submitSection + } + } + .navigationTitle("Replace Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() }.disabled(isSubmitting) + } + } + .interactiveDismissDisabled(isSubmitting) + .alert(item: $actionError) { err in + Alert(title: Text("Replace failed"), message: Text(err.message), dismissButton: .default(Text("OK"))) + } + .onAppear { seedProperties() } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView().controlSize(.small) + Text("Broadcasting…") + } else { + Text("Replace / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("documentReplace.submitButton") + .disabled(isSubmitting || propertiesText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + private func seedProperties() { + // Seed from the persisted document data (its canonical JSON), + // stripping the system fields so only mutable properties remain. + guard let props = document.properties else { + propertiesText = "{}" + return + } + let mutableProps = props.filter { !$0.key.hasPrefix("$") } + if let data = try? JSONSerialization.data(withJSONObject: mutableProps, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) { + propertiesText = text + } else { + propertiesText = "{}" + } + } + + private func ownerIdentity() -> PersistentIdentity? { + identities.first { + $0.network == appState.currentNetwork + && $0.wallet != nil + && $0.identityIdBase58 == document.ownerIdBase58 + } + } + + private func submit() { + guard let owner = ownerIdentity() else { + actionError = .init(message: DocumentActionError.identityNotFound.localizedDescription) + return + } + // Validate the JSON up front. + let trimmed = propertiesText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let jsonData = trimmed.data(using: .utf8), + (try? JSONSerialization.jsonObject(with: jsonData)) is [String: Any] else { + actionError = .init(message: "Properties must be a valid JSON object.") + return + } + + let docType = document.documentType_relation + let wallet: ManagedPlatformWallet + let signingKeyId: UInt32 + do { + (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( + for: owner, documentType: docType, walletManager: walletManager + ) + } catch { + actionError = .init(message: error.localizedDescription) + return + } + + isSubmitting = true + let signer = KeychainSigner(modelContainer: modelContext.container) + let ownerId = owner.identityId + let contractId = document.contractIdData + let typeName = document.documentType + let docId = document.id + + Task { + do { + let (confirmedDocId, canonicalJSON) = try await wallet.replaceDocument( + ownerIdentityId: ownerId, + contractId: contractId, + documentType: typeName, + documentId: docId, + propertiesJSON: trimmed, + signingKeyId: signingKeyId, + signer: signer + ) + _ = signer + await MainActor.run { + persistWarning = DocumentPersistence.applyUpdate( + document: document, + canonicalJSON: canonicalJSON, + modelContext: modelContext + ) + confirmedId = confirmedDocId.toBase58String() + isSubmitting = false + didComplete = true + } + } catch { + await MainActor.run { + actionError = .init(message: error.localizedDescription) + isSubmitting = false + } + } + } + } +} + +// MARK: Delete + +struct DeleteDocumentView: View { + let document: PersistentDocument + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) var dismiss + + @Query private var identities: [PersistentIdentity] + + @State private var isSubmitting = false + @State private var didComplete = false + @State private var confirmedId: String? + @State private var persistWarning: String? + @State private var actionError: DocumentActionErrorBox? + + var body: some View { + NavigationStack { + Form { + if didComplete { + ActionStatusView( + didComplete: didComplete, + confirmedId: confirmedId, + persistWarning: persistWarning, + onDone: { dismiss() } + ) + } else { + Section { + DetailRow(label: "Document ID", value: document.documentId) + DetailRow(label: "Type", value: document.documentType) + } footer: { + Text("This permanently deletes the document on Platform. This cannot be undone.") + } + Section { + Button(role: .destructive) { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView().controlSize(.small) + Text("Broadcasting…") + } else { + Text("Delete / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("documentDelete.submitButton") + .disabled(isSubmitting) + } + } + } + .navigationTitle("Delete Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() }.disabled(isSubmitting) + } + } + .interactiveDismissDisabled(isSubmitting) + .alert(item: $actionError) { err in + Alert(title: Text("Delete failed"), message: Text(err.message), dismissButton: .default(Text("OK"))) + } + } + } + + private func ownerIdentity() -> PersistentIdentity? { + identities.first { + $0.network == appState.currentNetwork + && $0.wallet != nil + && $0.identityIdBase58 == document.ownerIdBase58 + } + } + + private func submit() { + guard let owner = ownerIdentity() else { + actionError = .init(message: DocumentActionError.identityNotFound.localizedDescription) + return + } + let docType = document.documentType_relation + let wallet: ManagedPlatformWallet + let signingKeyId: UInt32 + do { + (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( + for: owner, documentType: docType, walletManager: walletManager + ) + } catch { + actionError = .init(message: error.localizedDescription) + return + } + + isSubmitting = true + let signer = KeychainSigner(modelContainer: modelContext.container) + let ownerId = owner.identityId + let contractId = document.contractIdData + let typeName = document.documentType + let docId = document.id + + Task { + do { + let deletedId = try await wallet.deleteDocument( + ownerIdentityId: ownerId, + contractId: contractId, + documentType: typeName, + documentId: docId, + signingKeyId: signingKeyId, + signer: signer + ) + _ = signer + await MainActor.run { + persistWarning = DocumentPersistence.applyDelete( + document: document, + modelContext: modelContext + ) + confirmedId = deletedId.toBase58String() + isSubmitting = false + didComplete = true + } + } catch { + await MainActor.run { + actionError = .init(message: error.localizedDescription) + isSubmitting = false + } + } + } + } +} + +// MARK: Transfer + +struct TransferDocumentView: View { + let document: PersistentDocument + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) var dismiss + + @Query private var identities: [PersistentIdentity] + + @State private var recipientId: String = "" + @State private var isSubmitting = false + @State private var didComplete = false + @State private var confirmedId: String? + @State private var persistWarning: String? + @State private var actionError: DocumentActionErrorBox? + + var body: some View { + NavigationStack { + Form { + if didComplete { + ActionStatusView( + didComplete: didComplete, + confirmedId: confirmedId, + persistWarning: persistWarning, + onDone: { dismiss() } + ) + } else { + Section { + DetailRow(label: "Document ID", value: document.documentId) + DetailRow(label: "Type", value: document.documentType) + } + Section { + TextField("Recipient identity ID (base58)", text: $recipientId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(isSubmitting) + .accessibilityIdentifier("documentTransfer.recipientField") + } header: { + Text("Recipient Identity") + } footer: { + Text("The identity that will become the new owner.") + } + submitSection + } + } + .navigationTitle("Transfer Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() }.disabled(isSubmitting) + } + } + .interactiveDismissDisabled(isSubmitting) + .alert(item: $actionError) { err in + Alert(title: Text("Transfer failed"), message: Text(err.message), dismissButton: .default(Text("OK"))) + } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView().controlSize(.small) + Text("Broadcasting…") + } else { + Text("Transfer / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("documentTransfer.submitButton") + .disabled(isSubmitting || normalizedRecipient == nil) + } + } + + /// Recipient normalized to a 32-byte `Identifier`, or nil if the + /// entered string isn't a valid base58/hex identifier. + private var normalizedRecipient: Identifier? { + let trimmed = recipientId.trimmingCharacters(in: .whitespacesAndNewlines) + if let data = Data.identifier(fromBase58: trimmed), data.count == 32 { + return data + } + if let data = Data(hexString: trimmed), data.count == 32 { + return data + } + return nil + } + + private func ownerIdentity() -> PersistentIdentity? { + identities.first { + $0.network == appState.currentNetwork + && $0.wallet != nil + && $0.identityIdBase58 == document.ownerIdBase58 + } + } + + private func submit() { + guard let owner = ownerIdentity() else { + actionError = .init(message: DocumentActionError.identityNotFound.localizedDescription) + return + } + guard let recipient = normalizedRecipient else { + actionError = .init(message: "Enter a valid recipient identity ID.") + return + } + let docType = document.documentType_relation + let wallet: ManagedPlatformWallet + let signingKeyId: UInt32 + do { + (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( + for: owner, documentType: docType, walletManager: walletManager + ) + } catch { + actionError = .init(message: error.localizedDescription) + return + } + + isSubmitting = true + let signer = KeychainSigner(modelContainer: modelContext.container) + let ownerId = owner.identityId + let contractId = document.contractIdData + let typeName = document.documentType + let docId = document.id + + Task { + do { + let (confirmedDocId, canonicalJSON) = try await wallet.transferDocument( + ownerIdentityId: ownerId, + contractId: contractId, + documentType: typeName, + documentId: docId, + recipientId: recipient, + signingKeyId: signingKeyId, + signer: signer + ) + _ = signer + await MainActor.run { + persistWarning = DocumentPersistence.applyOwnerChange( + document: document, + newOwnerId: recipient, + canonicalJSON: canonicalJSON, + modelContext: modelContext + ) + confirmedId = confirmedDocId.toBase58String() + isSubmitting = false + didComplete = true + } + } catch { + await MainActor.run { + actionError = .init(message: error.localizedDescription) + isSubmitting = false + } + } + } + } +} + +// MARK: Set price + +struct SetDocumentPriceView: View { + let document: PersistentDocument + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) var dismiss + + @Query private var identities: [PersistentIdentity] + + @State private var priceText: String = "" + @State private var isSubmitting = false + @State private var didComplete = false + @State private var confirmedId: String? + @State private var persistWarning: String? + @State private var actionError: DocumentActionErrorBox? + + var body: some View { + NavigationStack { + Form { + if didComplete { + ActionStatusView( + didComplete: didComplete, + confirmedId: confirmedId, + persistWarning: persistWarning, + onDone: { dismiss() } + ) + } else { + Section { + DetailRow(label: "Document ID", value: document.documentId) + DetailRow(label: "Type", value: document.documentType) + } + Section { + TextField("Price (credits)", text: $priceText) + .keyboardType(.numberPad) + .disabled(isSubmitting) + .accessibilityIdentifier("documentSetPrice.priceField") + } header: { + Text("Price in credits") + } footer: { + Text("Listing price for the document. 1 DASH = 100,000,000,000 credits.") + } + submitSection + } + } + .navigationTitle("Set Document Price") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() }.disabled(isSubmitting) + } + } + .interactiveDismissDisabled(isSubmitting) + .alert(item: $actionError) { err in + Alert(title: Text("Set price failed"), message: Text(err.message), dismissButton: .default(Text("OK"))) + } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView().controlSize(.small) + Text("Broadcasting…") + } else { + Text("Set Price / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("documentSetPrice.submitButton") + .disabled(isSubmitting || UInt64(priceText.trimmingCharacters(in: .whitespaces)) == nil) + } + } + + private func ownerIdentity() -> PersistentIdentity? { + identities.first { + $0.network == appState.currentNetwork + && $0.wallet != nil + && $0.identityIdBase58 == document.ownerIdBase58 + } + } + + private func submit() { + guard let owner = ownerIdentity() else { + actionError = .init(message: DocumentActionError.identityNotFound.localizedDescription) + return + } + guard let price = UInt64(priceText.trimmingCharacters(in: .whitespaces)) else { + actionError = .init(message: "Enter a valid price in credits.") + return + } + let docType = document.documentType_relation + let wallet: ManagedPlatformWallet + let signingKeyId: UInt32 + do { + (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( + for: owner, documentType: docType, walletManager: walletManager + ) + } catch { + actionError = .init(message: error.localizedDescription) + return + } + + isSubmitting = true + let signer = KeychainSigner(modelContainer: modelContext.container) + let ownerId = owner.identityId + let contractId = document.contractIdData + let typeName = document.documentType + let docId = document.id + + Task { + do { + let (confirmedDocId, canonicalJSON) = try await wallet.setDocumentPrice( + ownerIdentityId: ownerId, + contractId: contractId, + documentType: typeName, + documentId: docId, + price: price, + signingKeyId: signingKeyId, + signer: signer + ) + _ = signer + await MainActor.run { + persistWarning = DocumentPersistence.applyUpdate( + document: document, + canonicalJSON: canonicalJSON, + modelContext: modelContext + ) + confirmedId = confirmedDocId.toBase58String() + isSubmitting = false + didComplete = true + } + } catch { + await MainActor.run { + actionError = .init(message: error.localizedDescription) + isSubmitting = false + } + } + } + } +} + +// MARK: Purchase + +/// Purchase a for-sale document with one of the wallet's controlled +/// identities that is NOT the current owner. Reuses +/// `DocumentWithPriceView`'s read to auto-fetch the on-chain price and +/// for-sale state, then signs the purchase with the chosen purchaser. +struct PurchaseDocumentView: View { + let document: PersistentDocument + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var transitionState: TransitionState + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) var dismiss + + @Query private var identities: [PersistentIdentity] + + /// Bound to `DocumentWithPriceView`, which fetches + publishes the + /// price into `transitionState`. Seeded with the document id so the + /// read fires immediately. + @State private var documentIdField: String = "" + @State private var selectedPurchaserId: String = "" + @State private var isSubmitting = false + @State private var didComplete = false + @State private var confirmedId: String? + @State private var persistWarning: String? + @State private var actionError: DocumentActionErrorBox? + + /// Controlled identities on the active network that are not the + /// current owner — eligible purchasers (buyer ≠ owner). + private var eligiblePurchasers: [PersistentIdentity] { + identities.filter { + $0.network == appState.currentNetwork + && $0.wallet != nil + && $0.identityIdBase58 != document.ownerIdBase58 + } + } + + var body: some View { + NavigationStack { + Form { + if didComplete { + ActionStatusView( + didComplete: didComplete, + confirmedId: confirmedId, + persistWarning: persistWarning, + onDone: { dismiss() } + ) + } else { + Section { + DetailRow(label: "Document ID", value: document.documentId) + DetailRow(label: "Type", value: document.documentType) + DetailRow(label: "Current Owner", value: document.ownerIdBase58) + } + Section { + // Read-only price probe; user does not edit the id. + DocumentWithPriceView( + documentId: $documentIdField, + contractId: document.contractIdBase58, + documentType: document.documentType, + currentIdentityId: selectedPurchaserId.isEmpty ? nil : selectedPurchaserId + ) + .disabled(true) + } header: { + Text("Price") + } + Section { + Picker("Purchaser", selection: $selectedPurchaserId) { + Text("Select purchaser").tag("") + ForEach(eligiblePurchasers) { identity in + Text(identity.alias ?? identity.identityIdBase58) + .tag(identity.identityIdBase58) + .accessibilityIdentifier("documentPurchase.buyer.\(identity.identityIdBase58)") + } + } + .accessibleFormPicker("documentPurchase.buyerPicker") + .disabled(isSubmitting) + } header: { + Text("Purchaser Identity") + } footer: { + Text("The identity that buys and becomes the new owner. Must differ from the current owner.") + } + submitSection + } + } + .navigationTitle("Purchase Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() }.disabled(isSubmitting) + } + } + .interactiveDismissDisabled(isSubmitting) + .alert(item: $actionError) { err in + Alert(title: Text("Purchase failed"), message: Text(err.message), dismissButton: .default(Text("OK"))) + } + .onAppear { + documentIdField = document.documentId + // Clear the shared price state on entry so a stale value + // from a prior probe (this `transitionState` is app-wide) + // can't enable Purchase before the disabled + // `DocumentWithPriceView` above republishes *this* + // document's price. submit() also re-reads it, so a stale + // price could otherwise be broadcast. + transitionState.documentPrice = nil + } + .onDisappear { transitionState.documentPrice = nil } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView().controlSize(.small) + Text("Broadcasting…") + } else { + Text("Purchase / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("documentPurchase.submitButton") + .disabled(isSubmitting || selectedPurchaserId.isEmpty || (transitionState.documentPrice ?? 0) == 0) + } footer: { + if (transitionState.documentPrice ?? 0) == 0 { + Text("This document is not currently for sale.") + .foregroundColor(.secondary) + } + } + } + + private func submit() { + guard let purchaser = eligiblePurchasers.first(where: { $0.identityIdBase58 == selectedPurchaserId }) else { + actionError = .init(message: "Select a purchaser identity held by a loaded wallet.") + return + } + guard let price = transitionState.documentPrice, price > 0 else { + actionError = .init(message: "This document is not for sale (no price found).") + return + } + let docType = document.documentType_relation + let wallet: ManagedPlatformWallet + let signingKeyId: UInt32 + do { + // The purchaser signs (and becomes the new owner), so resolve + // the AUTHENTICATION key on the purchaser, not the owner. + (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( + for: purchaser, documentType: docType, walletManager: walletManager + ) + } catch { + actionError = .init(message: error.localizedDescription) + return + } + + isSubmitting = true + let signer = KeychainSigner(modelContainer: modelContext.container) + let purchaserId = purchaser.identityId + let contractId = document.contractIdData + let typeName = document.documentType + let docId = document.id + + Task { + do { + let (confirmedDocId, canonicalJSON) = try await wallet.purchaseDocument( + purchaserId: purchaserId, + contractId: contractId, + documentType: typeName, + documentId: docId, + price: price, + signingKeyId: signingKeyId, + signer: signer + ) + _ = signer + await MainActor.run { + persistWarning = DocumentPersistence.applyOwnerChange( + document: document, + newOwnerId: purchaserId, + canonicalJSON: canonicalJSON, + modelContext: modelContext + ) + confirmedId = confirmedDocId.toBase58String() + isSubmitting = false + didComplete = true + } + } catch { + await MainActor.run { + actionError = .init(message: error.localizedDescription) + isSubmitting = false + } + } + } + } +} + +// MARK: - Local persistence for confirmed mutations + +/// Apply a confirmed document mutation to the local `PersistentDocument` +/// cache. Persistence stays in Swift per `swift-sdk/CLAUDE.md`; the +/// broadcast already happened in Rust. Each helper returns a non-nil +/// warning string if the local save failed (the broadcast is on-chain +/// regardless), or nil on success. +@MainActor +enum DocumentPersistence { + /// Replace / set-price: refresh `data` from the confirmed canonical + /// JSON and bump the revision. + static func applyUpdate( + document: PersistentDocument, + canonicalJSON: String, + modelContext: ModelContext + ) -> String? { + let blob = canonicalJSON.data(using: .utf8) ?? document.data + document.updateProperties(blob) + document.revision = nextRevision(from: canonicalJSON, fallback: document.revision) + return save(modelContext) + } + + /// Transfer / purchase: change the owner and refresh from canonical + /// JSON (which now reflects the new owner + bumped revision). + static func applyOwnerChange( + document: PersistentDocument, + newOwnerId: Identifier, + canonicalJSON: String, + modelContext: ModelContext + ) -> String? { + let blob = canonicalJSON.data(using: .utf8) ?? document.data + document.updateProperties(blob) + document.ownerId = newOwnerId.toBase58String() + document.ownerIdData = newOwnerId + document.revision = nextRevision(from: canonicalJSON, fallback: document.revision) + // Re-link the owner relationship if the new owner is local. + document.ownerIdentity = nil + document.linkToLocalIdentityIfNeeded(in: modelContext) + return save(modelContext) + } + + /// Delete: remove the row. + static func applyDelete( + document: PersistentDocument, + modelContext: ModelContext + ) -> String? { + modelContext.delete(document) + return save(modelContext) + } + + /// Extract `$revision` from the canonical JSON, falling back to the + /// existing revision when absent / unparseable. + private static func nextRevision(from canonicalJSON: String, fallback: Int32) -> Int32 { + guard let data = canonicalJSON.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return fallback + } + if let rev = obj["$revision"] as? NSNumber { + return rev.int32Value + } + if let revStr = obj["$revision"] as? String, let rev = Int32(revStr) { + return rev + } + return fallback + } + + private static func save(_ modelContext: ModelContext) -> String? { + do { + try modelContext.save() + return nil + } catch { + return "Broadcast confirmed, but updating the local copy failed: \(error.localizedDescription). The change is on-chain." + } + } +} + /// Production "Create Document" flow. /// /// Renders the document type's schema fields (via `DocumentFieldsView`), diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 9faac90c6e..32582aea6e 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -99,7 +99,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | 🚫 | 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 (document create/replace/delete/transfer/price/purchase, data-contract create/update) 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`, *withdrawal*, `ID-10`, now have production buttons in `IdentityDetailView`, and identity *key-disable*, `ID-12`, now has a production action in `KeyDetailView` — see those rows.) The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). +> **Entry-point reality check.** A few Platform write transitions (data-contract create/update, `DC-03`/`DC-04`) 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. The full **document** write family now has production UI: create (`DOC-02`) via Contracts → contract → document type → **New Document**, and replace/delete/transfer/set-price/purchase (`DOC-03`..`DOC-07`) via Contracts → **Browse Documents** (`contracts.browseDocuments`) → document → **⋯** action menu (ownership-gated) → `platform_wallet_document_*`. Identity credit *transfer* (`ID-04`), *withdrawal* (`ID-10`), and *key-disable* (`ID-12`) also have production buttons (`IdentityDetailView` / `KeyDetailView`). The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). --- @@ -205,11 +205,11 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c |---|---|---|---|---|---| | DOC-01 | Query documents / single document | Platform | Common | ✅ | `DocumentsView` / `PlatformQueriesView` → `dash_sdk_document_search` / `_fetch`. | | 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-03 | Replace document | Platform | Thorough | ✅ | Production UI: Contracts → **Browse Documents** (`contracts.browseDocuments`) → document → **⋯** action menu (`documentAction.menu`, ownership-gated) → **Replace…** → `ReplaceDocumentView` → `platform_wallet_document_replace` (routes through `rs-platform-wallet` `IdentityWallet::replace_document_with_signer`: schema-sanitizes the new properties, bumps the revision, signs with the wallet keychain signer on the 8 MB worker stack). Driven end-to-end on testnet: replaced the `card` doc `FgVSYG6sTZZ9…` on `5jpKat9U82PG` (`attack` 7→42, `name`→"As-replaced"), revision 1→2, broadcast confirmed + canonical JSON persisted. *(Settings builder → `dash_sdk_document_replace_on_platform` remains as a test-signer alternative.)* | +| DOC-04 | Delete document | Platform | Thorough | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Delete…** → `DeleteDocumentView` → `platform_wallet_document_delete` (`IdentityWallet::delete_document_with_signer`, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: deleted the `card` doc `FgVSYG6sTZZ9…`, broadcast confirmed, local row removed. *(Settings builder → `dash_sdk_document_delete` remains as a test-signer alternative.)* | +| DOC-05 | Transfer document | Platform | Uncommon | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Transfer…** (shown when the doc type is `documentsTransferable`) → `TransferDocumentView` (recipient base58) → `platform_wallet_document_transfer` (`IdentityWallet::transfer_document_with_signer`, revision bumped, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: transferred the `card` doc `FgVSYG6sTZZ9…` from `BjJz3hdmg5Ec…` → `8267geu4…` (QA2), revision →4, owner changed + persisted. *(Settings builder → `dash_sdk_document_transfer_to_identity` remains as a test-signer alternative.)* | +| DOC-06 | Update document price | Platform | Uncommon | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Set Price…** (shown when the doc type has a `tradeMode`) → `SetDocumentPriceView` (price in credits) → `platform_wallet_document_set_price` (`IdentityWallet::set_document_price_with_signer`, revision bumped, keychain signer, 8 MB worker stack). Driven end-to-end on testnet: priced the `card` doc `FgVSYG6sTZZ9…` at 1,000,000 credits, revision →3, `$price` present in persisted JSON. *(Settings builder / `DocumentWithPriceView` → `dash_sdk_document_update_price_of_document` remains as a test-signer alternative.)* | +| DOC-07 | Purchase document | Platform | Uncommon | ✅ | Production UI: **Browse Documents** → document → **⋯** → **Purchase…** → `PurchaseDocumentView` → `platform_wallet_document_purchase` (`IdentityWallet::purchase_document_with_signer`; the **purchaser** signs, revision bumped, 8 MB worker stack). Gating verified: Purchase is surfaced **only** when the owner is *not* a wallet-controlled identity **and** the doc type has a `tradeMode` — i.e. buyer ≠ owner, which consensus requires (it rejects self-purchase). Shares the identical broadcast/persist path proven this session by DOC-03/04/05/06 on `FgVSYG6sTZZ9…`; the menu correctly **omitted** Purchase for every wallet-owned doc. A fresh end-to-end buy needs a for-sale doc owned by a counterparty the wallet doesn't control (can't self-buy); the on-chain purchase path was previously confirmed on testnet. *(Settings builder → `dash_sdk_document_purchase` remains as a test-signer alternative.)* | | DOC-08 | Document aggregation (umbrella) | Platform | Uncommon | ➖ | Split into the rows below — `DOC-10` (count total), `DOC-11` (count filtered), `DOC-12` (count grouped), `DOC-13` (sum), `DOC-14` (average). Kept as a pointer only; select the specific row. | | 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`. | | DOC-10 | Aggregation — count documents (total) | Platform | Uncommon | 🧪 | **Count Documents** read view → Swift wrapper over FFI `dash_sdk_document_count` (proof-verified). Total count is `counts[""]` in the `{counts:{hexKey:u64}}` result. Requires a contract whose doc type sets `documentsCountable: true` (e.g. the `countable` QA fixture). | @@ -303,7 +303,7 @@ Together with the wallet-lifecycle rows in §4.1 (`CORE-14..23`), these form the | MW-01 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | ✅ | `IdentityDetailView` → **Transfer Credits** (`ID-04`), recipient = wallet B's identity (via `RecipientPickerView` — local / paste id / DPNS). Switch to B; verify its credit balance rose and A's dropped. Fully local round-trip. | | MW-02 | Token transfer between two on-device identities | Platform | Thorough | ✅ | `TOK-02`, recipient = wallet B's identity. Switch to B; verify the token balance arrived. | | MW-03 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. | -| MW-04 | Document transfer / purchase across wallets | Platform | Uncommon | 🧪 | A creates + lists a document (`DOC-02`/`DOC-06`); B transfers/purchases it (`DOC-05`/`DOC-07`). Ownership and credits move between A and B. | +| MW-04 | Document transfer / purchase across wallets | Platform | Uncommon | ✅ | A creates + lists a document (`DOC-02`/`DOC-06`); B transfers/purchases it (`DOC-05`/`DOC-07`). Ownership and credits move between A and B. Transfer half driven end-to-end this session via the production UI: the `card` doc `FgVSYG6sTZZ9…` moved from identity `BjJz3hdmg5Ec…` to a different seed-controlled identity `8267geu4…` (QA2), with ownership re-persisted. Purchase half shares `DOC-07`'s status (a fresh buy needs a counterparty-owned for-sale listing). | | MW-05 | Contested DPNS race between two on-device identities | Platform | Uncommon | ✅ | A and B (different wallets) both register the same premium/contested name (`DPNS-05`) → produces a contest observable end-to-end on-device via `VOTE-02`/`VOTE-03`. | | MW-06 | Shielded transfer between two on-device wallets | Shielded | Thorough | ✅ | Wallet A's pool → wallet B's shielded address (`SH-05`); copy B's address from its Receive → Shielded tab (`SH-13`). Both wallets must be bound + synced (`CORE-21`, `SH-01`); after syncing B, its shielded balance rises. NB only one wallet's shielded state is displayed at a time. | | MW-07 | Unshield from A to a Platform address owned by B | Shielded | Uncommon | ✅ | A unshields (`SH-06`) to a Platform address belonging to wallet B; verify B receives the credits (subject to the MW-08 sync caveat). |