From 992be090a589b3859c90478e5cc1609ef26bb2b6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 00:18:31 +0700 Subject: [PATCH 01/54] feat(dpp,sdk): add signer-based asset-lock identity creation + top-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing external-Signer pathway for asset-lock-funded IdentityCreate / IdentityTopUp state transitions. Previously these required raw `&PrivateKey` bytes for the asset-lock-proof signature, making the flow impossible on watch-only / ExternalSignable wallets where no private keys live host-side. Additive (no breaking changes to existing callers): - `StateTransition::sign_with_signer` — sibling to `sign_by_private_key`. Atomic per-call derive+sign+zero via the supplied signer. Byte-parity proven against the legacy path (test pins on-wire compatibility). - `IdentityCreateTransitionV0::try_from_identity_with_signers` and `IdentityTopUpTransitionV0::try_from_identity_with_signer` — new signer-based factories alongside the renamed legacy `_with_signer_and_private_key` / `_with_private_key` siblings. - `PutIdentity::put_to_platform_with_signer`, `BroadcastNewIdentity::broadcast_request_for_new_identity_with_signer`, `TopUpIdentity::top_up_identity_with_signer` — rs-sdk wrappers, gated on `core_key_wallet` feature. - `ProtocolError::ExternalSignerError(String)` — typed variant so callers can distinguish signer-side failures from generic protocol errors (recovery-id mismatch invariant violations etc.). The legacy `try_from_identity_with_signer` was renamed to `try_from_identity_with_signer_and_private_key` (and the top-up counterpart `try_from_identity` to `try_from_identity_with_private_key`) so callers can read the contract at a glance. Call sites in rs-sdk, rs-sdk-ffi, wasm-sdk, drive-abci, and strategy-tests propagated. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-dpp/src/errors/protocol_error.rs | 18 ++ packages/rs-dpp/src/state_transition/mod.rs | 248 ++++++++++++++++++ .../identity_create_transition/methods/mod.rs | 50 +++- .../methods/v0/mod.rs | 42 ++- .../v0/v0_methods.rs | 64 ++++- .../identity_topup_transition/methods/mod.rs | 47 +++- .../methods/v0/mod.rs | 30 ++- .../v0/v0_methods.rs | 37 ++- .../src/execution/check_tx/v0/mod.rs | 26 +- .../address_funding_from_asset_lock/tests.rs | 4 +- .../state_transitions/identity_create/mod.rs | 28 +- .../state_transitions/identity_top_up/mod.rs | 6 +- .../tests/strategy_tests/strategy.rs | 2 +- packages/rs-sdk-ffi/src/identity/put.rs | 8 +- packages/rs-sdk-ffi/src/identity/topup.rs | 4 +- .../platform/transition/broadcast_identity.rs | 65 ++++- .../src/platform/transition/put_identity.rs | 151 ++++++++++- .../platform/transition/top_up_identity.rs | 65 ++++- packages/strategy-tests/src/transitions.rs | 8 +- .../src/state_transitions/identity.rs | 4 +- 20 files changed, 831 insertions(+), 76 deletions(-) diff --git a/packages/rs-dpp/src/errors/protocol_error.rs b/packages/rs-dpp/src/errors/protocol_error.rs index e0497c685b1..825bf832991 100644 --- a/packages/rs-dpp/src/errors/protocol_error.rs +++ b/packages/rs-dpp/src/errors/protocol_error.rs @@ -140,6 +140,24 @@ pub enum ProtocolError { #[error("Generic Error: {0}")] Generic(String), + /// External signer (e.g. Swift Keychain-backed + /// `MnemonicResolverCoreSigner`) reported a failure or returned a + /// non-conformant result. This is a distinct surface from + /// [`Self::Generic`] so callers (FFI layer, retry policies) can + /// distinguish a signer-side failure from a generic protocol + /// invariant. + /// + /// Examples of failures that surface here: + /// - The signer's `sign_ecdsa` future returned an error (resolver + /// miss, derivation failure, invalid mnemonic, etc.). + /// - The signer returned a signature whose recovery id does not + /// match the returned public key (invariant violation by a + /// non-conformant signer — distinct from a soft failure, but + /// surfaced uniformly here so the FFI layer doesn't need to + /// pattern-match on signer-specific shapes). + #[error("External signer error: {0}")] + ExternalSignerError(String), + #[error("Address witness verification error: {0}")] AddressWitnessError(String), diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index d00c1cf4226..f55dc8d2b91 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1245,6 +1245,130 @@ impl StateTransition { Ok(()) } + /// Signs `self.signable_bytes()` with an external [`key_wallet::signer::Signer`] + /// and stores the resulting Core-ECDSA signature on the state transition. + /// + /// This is the signer-driven counterpart to [`Self::sign_by_private_key`] for + /// the ECDSA (asset-lock-proof) signing path. It exists so callers — most + /// notably the iOS Swift SDK and any other host that holds its private keys + /// inside a hardware wallet, secure enclave, or remote signing service — can + /// produce the asset-lock-proof signature on `IdentityCreate` / + /// `IdentityTopUp` state transitions **without ever materialising a raw + /// `[u8]` private key on the Rust side**. The signer performs the + /// derive + sign + zeroise sequence atomically inside its own trust + /// boundary; this function only sees a 32-byte digest and the resulting + /// signature. + /// + /// # Wire-format parity with `sign_by_private_key` + /// + /// The byte layout of the signature stored on `self` is **byte-identical** + /// to the one produced by [`Self::sign_by_private_key`] when called with + /// `KeyType::ECDSA_SECP256K1` / `KeyType::ECDSA_HASH160` and the same + /// underlying private key. The pre-image transform mirrors + /// `dashcore::signer::sign`: + /// + /// 1. `digest = double_sha256(self.signable_bytes()?)` + /// 2. `signer.sign_ecdsa(path, digest).await` → non-recoverable + /// `(secp256k1::ecdsa::Signature, secp256k1::PublicKey)`. + /// 3. Recover the recovery id by trying all four candidates against the + /// returned public key (libsecp256k1 normalises both signing paths to + /// low-s form so the 64-byte `r||s` payload is bit-identical). + /// 4. Serialise as a 65-byte compact recoverable signature with the + /// `compressed` prefix convention used by `CompactSignature` — i.e. + /// `[recovery_id + 27 + 4, r (32) || s (32)]`. + /// + /// # Errors + /// + /// - Returns [`ProtocolError::ExternalSignerError`] wrapping the signer's + /// `Display` error when the underlying signer fails. + /// - Returns [`ProtocolError::ExternalSignerError`] if no recovery id + /// matches the public key returned by the signer — this should be + /// unreachable for a conformant signer (invariant violation by a + /// non-conformant signer) but is surfaced rather than panicked on. + /// - Returns [`ProtocolError::Generic`] if the SHA-256 transform did not + /// yield a 32-byte digest (defensive — should never happen). + /// - Returns [`ProtocolError::InvalidVerificationWrongNumberOfElements`] if + /// `set_signature` rejects the result (matches `sign_by_private_key`). + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + pub async fn sign_with_signer( + &mut self, + path: &::key_wallet::bip32::DerivationPath, + signer: &S, + ) -> Result<(), ProtocolError> { + use dashcore::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; + use dashcore::secp256k1::{Message, Secp256k1}; + use dashcore::signer::{double_sha, CompactSignature}; + + let data = self.signable_bytes()?; + // Pre-image transform matches `dashcore::signer::sign`: double-SHA256 + // of the signable bytes is the actual ECDSA message digest. + let data_hash = double_sha(&data); + let digest: [u8; 32] = data_hash + .as_slice() + .try_into() + .map_err(|_| ProtocolError::Generic("double_sha did not return 32 bytes".to_string()))?; + + let (signature, public_key) = signer + .sign_ecdsa(path, digest) + .await + .map_err(|e| ProtocolError::ExternalSignerError(format!("signer failed: {}", e)))?; + + // The signer returns a non-recoverable signature. The legacy path + // stores a 65-byte recoverable compact signature, so we brute-force + // the recovery id (0..3) by reconstructing a `RecoverableSignature` + // and comparing the recovered public key with the one the signer + // returned. secp256k1 normalises both `sign_ecdsa` and + // `sign_ecdsa_recoverable` outputs to low-s form, so the 64-byte + // `r||s` payload is bit-identical to what `dashcore::signer::sign` + // produces. + let compact_64 = signature.serialize_compact(); + let secp = Secp256k1::new(); + let msg = Message::from_digest(digest); + + let mut found: Option = None; + for id in 0..4i32 { + let recid = match RecoveryId::try_from(id) { + Ok(r) => r, + Err(_) => continue, + }; + let candidate = match RecoverableSignature::from_compact(&compact_64, recid) { + Ok(s) => s, + Err(_) => continue, + }; + if let Ok(recovered) = secp.recover_ecdsa(&msg, &candidate) { + if recovered == public_key { + found = Some(candidate); + break; + } + } + } + let recoverable = found.ok_or_else(|| { + // Invariant violation by a non-conformant signer: the + // signature returned does not correspond to the public + // key the signer claims. Surface as ExternalSignerError + // (NOT Generic) so callers can distinguish signer-side + // failures from protocol-level invariants. + ProtocolError::ExternalSignerError( + "signer returned a signature whose recovery id does not match the returned public key".to_string(), + ) + })?; + + // Compressed-pubkey convention matches `dashcore::signer::sign`, which + // always passes `true` regardless of the underlying key encoding. The + // signer's `sign_ecdsa` returns the compressed `secp256k1::PublicKey`, + // so this is consistent. + let compact_65 = recoverable.to_compact_signature(true); + + if !self.set_signature(compact_65.to_vec().into()) { + return Err(ProtocolError::InvalidVerificationWrongNumberOfElements { + needed: self.required_number_of_private_keys(), + using: 1, + msg: "failed to set ECDSA signature", + }); + } + Ok(()) + } + #[cfg(feature = "state-transition-validation")] fn verify_by_raw_public_key( &self, @@ -3120,4 +3244,128 @@ mod tests { .unique_identifiers() .is_empty()); } + + // ----------------------------------------------------------------------- + // sign_with_signer byte-parity test + // + // Proves that `StateTransition::sign_with_signer` produces a byte-identical + // signature to the legacy `sign_by_private_key` ECDSA path when both are + // driven by the same underlying secret. This is the on-wire contract the + // Swift / external-signer flow depends on: changing the digest pre-image + // or the recoverable-compact encoding would silently break asset-lock + // verification on testnet/mainnet, so we pin both shapes here. + // ----------------------------------------------------------------------- + #[cfg(all( + feature = "state-transition-signing", + feature = "core_key_wallet", + feature = "bls-signatures" + ))] + #[tokio::test] + async fn sign_with_signer_matches_sign_by_private_key_byte_for_byte() { + use async_trait::async_trait; + use dashcore::secp256k1::{ + ecdsa, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey, + }; + use key_wallet::bip32::DerivationPath; + use key_wallet::signer::{Signer as KwSigner, SignerMethod}; + + /// Fixed-key in-memory signer used only by this test. Mirrors how a + /// real KeychainSigner would behave: derive once, sign atomically, + /// return non-recoverable `(Signature, PublicKey)`. The path is + /// ignored — the wrapper holds exactly one key. + #[derive(Debug)] + struct FixedKeySigner { + secret: SecretKey, + public: PublicKey, + } + + #[async_trait] + impl KwSigner for FixedKeySigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + let secp = Secp256k1::new(); + let msg = Message::from_digest(sighash); + let sig = secp.sign_ecdsa(&msg, &self.secret); + Ok((sig, self.public)) + } + + async fn public_key( + &self, + _path: &DerivationPath, + ) -> Result { + Ok(self.public) + } + } + + // Generate a single random key. Using the same key on both sides is + // load-bearing: the legacy path signs raw bytes, the signer path + // derives + signs inside the trust boundary. If the digest pre-image + // or compact-encoding differs, the bytes will diverge. + let secp = Secp256k1::new(); + let (secret_key, public_key) = secp.generate_keypair(&mut OsRng); + let private_key_bytes = secret_key.secret_bytes(); + + let signer = FixedKeySigner { + secret: secret_key, + public: public_key, + }; + let path = DerivationPath::default(); + + // Use a sample state transition that exercises signable_bytes() — + // any signable ST works since we're only comparing the signature + // bytes the two paths produce over the SAME `signable_bytes()`. + let mut st_legacy = sample_transfer_st(); + let mut st_signer = sample_transfer_st(); + + // Sanity: both copies must have identical signable_bytes before signing. + assert_eq!( + st_legacy.signable_bytes().expect("legacy signable_bytes"), + st_signer.signable_bytes().expect("signer signable_bytes"), + "signable_bytes pre-image must match across copies" + ); + + // Legacy path: raw &[u8] private key → 65-byte recoverable compact. + // BLS is only used by `sign_by_private_key` when key_type is BLS12_381 — + // for the ECDSA path it's unused, but the function signature requires + // it, so we pass the NativeBlsModule that's already in the workspace. + let bls = crate::bls::native_bls::NativeBlsModule; + st_legacy + .sign_by_private_key(&private_key_bytes, KeyType::ECDSA_HASH160, &bls) + .expect("sign_by_private_key"); + + // New signer-driven path: digest → external signer → recovered → + // 65-byte recoverable compact. Byte-identical to the legacy result. + st_signer + .sign_with_signer(&path, &signer) + .await + .expect("sign_with_signer"); + + let sig_legacy = st_legacy.signature().expect("legacy signature set"); + let sig_signer = st_signer.signature().expect("signer signature set"); + + assert_eq!( + sig_legacy.as_slice().len(), + 65, + "legacy ECDSA signature must be 65 bytes (recoverable compact)" + ); + assert_eq!( + sig_signer.as_slice().len(), + 65, + "signer ECDSA signature must be 65 bytes (recoverable compact)" + ); + assert_eq!( + sig_legacy.as_slice(), + sig_signer.as_slice(), + "sign_with_signer must produce byte-identical output to sign_by_private_key" + ); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/mod.rs index 8f622bbd0ba..d5bc5a345a0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/mod.rs @@ -24,7 +24,7 @@ use crate::version::PlatformVersion; use crate::{BlsModule, ProtocolError}; impl IdentityCreateTransitionMethodsV0 for IdentityCreateTransition { #[cfg(feature = "state-transition-signing")] - async fn try_from_identity_with_signer>( + async fn try_from_identity_with_signer_and_private_key>( identity: &Identity, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], @@ -38,18 +38,58 @@ impl IdentityCreateTransitionMethodsV0 for IdentityCreateTransition { .state_transition_conversion_versions .identity_to_identity_create_transition_with_signer { - 0 => Ok(IdentityCreateTransitionV0::try_from_identity_with_signer( + 0 => Ok( + IdentityCreateTransitionV0::try_from_identity_with_signer_and_private_key( + identity, + asset_lock_proof, + asset_lock_proof_private_key, + signer, + bls, + user_fee_increase, + platform_version, + ) + .await?, + ), + v => Err(ProtocolError::UnknownVersionError(format!( + "Unknown IdentityCreateTransition version for try_from_identity_with_signer_and_private_key {v}" + ))), + } + } + + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + #[allow(clippy::too_many_arguments)] + async fn try_from_identity_with_signers( + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + identity_signer: &IS, + asset_lock_signer: &AS, + bls: &impl BlsModule, + user_fee_increase: UserFeeIncrease, + platform_version: &PlatformVersion, + ) -> Result + where + IS: Signer, + AS: ::key_wallet::signer::Signer, + { + match platform_version + .dpp + .state_transition_conversion_versions + .identity_to_identity_create_transition_with_signer + { + 0 => Ok(IdentityCreateTransitionV0::try_from_identity_with_signers( identity, asset_lock_proof, - asset_lock_proof_private_key, - signer, + asset_lock_proof_path, + identity_signer, + asset_lock_signer, bls, user_fee_increase, platform_version, ) .await?), v => Err(ProtocolError::UnknownVersionError(format!( - "Unknown IdentityCreateTransition version for try_from_identity_with_signer {v}" + "Unknown IdentityCreateTransition version for try_from_identity_with_signers {v}" ))), } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/v0/mod.rs index 561feb305a1..e34d05dc195 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/methods/v0/mod.rs @@ -17,8 +17,21 @@ use crate::{BlsModule, ProtocolError}; use platform_version::version::PlatformVersion; pub trait IdentityCreateTransitionMethodsV0 { + /// Build an `IdentityCreate` state transition that holds the + /// asset-lock-proof private key in-process. + /// + /// The `signer` parameter signs each `IdentityPublicKey` witness (the + /// per-key signatures in `public_keys[]`), while the asset-lock-proof + /// signature on the outer state transition is produced from + /// `asset_lock_proof_private_key` via [`StateTransition::sign_by_private_key`]. + /// + /// Prefer [`Self::try_from_identity_with_signers`] when the asset-lock + /// key lives outside Rust (Swift / hardware wallet / HSM): the + /// `_with_signers` variant routes asset-lock signing through an external + /// [`key_wallet::signer::Signer`] so the private key never crosses the FFI + /// boundary as raw bytes. #[cfg(feature = "state-transition-signing")] - async fn try_from_identity_with_signer>( + async fn try_from_identity_with_signer_and_private_key>( identity: &Identity, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], @@ -27,6 +40,33 @@ pub trait IdentityCreateTransitionMethodsV0 { user_fee_increase: UserFeeIncrease, platform_version: &PlatformVersion, ) -> Result; + + /// Build an `IdentityCreate` state transition where the asset-lock-proof + /// signature is produced by an external [`key_wallet::signer::Signer`]. + /// + /// `identity_signer` signs the per-key witnesses on `public_keys[]` (same + /// as the legacy `try_from_identity_with_signer_and_private_key` path), + /// while `asset_lock_signer` produces the outer state-transition ECDSA + /// signature for the key at `asset_lock_proof_path` — atomically deriving, + /// signing, and zeroising inside the signer's trust boundary. This is the + /// signing path used by hosts that hold their private keys outside Rust + /// (the iOS Swift SDK, hardware wallets, remote signers). + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + #[allow(clippy::too_many_arguments)] + async fn try_from_identity_with_signers( + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + identity_signer: &IS, + asset_lock_signer: &AS, + bls: &impl BlsModule, + user_fee_increase: UserFeeIncrease, + platform_version: &PlatformVersion, + ) -> Result + where + IS: Signer, + AS: ::key_wallet::signer::Signer; + /// Get State Transition type fn get_type() -> StateTransitionType; } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs index 2a342b2b8d1..bdfff7bebab 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs @@ -35,7 +35,7 @@ use crate::state_transition::StateTransition; use crate::version::PlatformVersion; impl IdentityCreateTransitionMethodsV0 for IdentityCreateTransitionV0 { #[cfg(feature = "state-transition-signing")] - async fn try_from_identity_with_signer>( + async fn try_from_identity_with_signer_and_private_key>( identity: &Identity, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], @@ -80,6 +80,68 @@ impl IdentityCreateTransitionMethodsV0 for IdentityCreateTransitionV0 { Ok(state_transition) } + /// Signer-driven counterpart to + /// [`Self::try_from_identity_with_signer_and_private_key`]: the + /// asset-lock-proof signature is produced by an external + /// [`key_wallet::signer::Signer`] rather than from a raw + /// `&[u8]` private key. See trait docs for the architectural rationale. + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + #[allow(clippy::too_many_arguments)] + async fn try_from_identity_with_signers( + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + identity_signer: &IS, + asset_lock_signer: &AS, + _bls: &impl BlsModule, + user_fee_increase: UserFeeIncrease, + _platform_version: &PlatformVersion, + ) -> Result + where + IS: Signer, + AS: ::key_wallet::signer::Signer, + { + let mut identity_create_transition = IdentityCreateTransitionV0 { + user_fee_increase, + ..Default::default() + }; + let public_keys = identity + .public_keys() + .values() + .map(|public_key| public_key.clone().into()) + .collect(); + identity_create_transition.set_public_keys(public_keys); + + identity_create_transition.set_asset_lock_proof(asset_lock_proof)?; + + //todo: remove clone + let state_transition: StateTransition = identity_create_transition.clone().into(); + + let key_signable_bytes = state_transition.signable_bytes()?; + + for (public_key_with_witness, (_, public_key)) in identity_create_transition + .public_keys + .iter_mut() + .zip(identity.public_keys().iter()) + { + if public_key.key_type().is_unique_key_type() { + let signature = identity_signer.sign(public_key, &key_signable_bytes).await?; + public_key_with_witness.set_signature(signature); + } + } + + let mut state_transition: StateTransition = identity_create_transition.into(); + + // Atomic derive + sign + zeroise happens inside the signer. The host + // never sees a raw private key — only a 32-byte digest goes in and a + // serialised signature comes out. + state_transition + .sign_with_signer(asset_lock_proof_path, asset_lock_signer) + .await?; + + Ok(state_transition) + } + /// Get State Transition type fn get_type() -> StateTransitionType { StateTransitionType::IdentityCreate diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/mod.rs index 243117c2945..5053cecac77 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/mod.rs @@ -21,7 +21,7 @@ use platform_version::version::PlatformVersion; impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransition { #[cfg(feature = "state-transition-signing")] - fn try_from_identity( + fn try_from_identity_with_private_key( identity: &Identity, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], @@ -35,16 +35,53 @@ impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransition { .state_transition_conversion_versions .identity_to_identity_top_up_transition, ) { - 0 => Ok(IdentityTopUpTransitionV0::try_from_identity( + 0 => Ok( + IdentityTopUpTransitionV0::try_from_identity_with_private_key( + identity, + asset_lock_proof, + asset_lock_proof_private_key, + user_fee_increase, + platform_version, + version, + )?, + ), + v => Err(ProtocolError::UnknownVersionError(format!( + "Unknown IdentityTopUpTransition version for try_from_identity_with_private_key {v}" + ))), + } + } + + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_identity_with_signer( + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + user_fee_increase: UserFeeIncrease, + platform_version: &PlatformVersion, + version: Option, + ) -> Result + where + AS: ::key_wallet::signer::Signer, + { + match version.unwrap_or( + platform_version + .dpp + .state_transition_conversion_versions + .identity_to_identity_top_up_transition, + ) { + 0 => Ok(IdentityTopUpTransitionV0::try_from_identity_with_signer( identity, asset_lock_proof, - asset_lock_proof_private_key, + asset_lock_proof_path, + asset_lock_signer, user_fee_increase, platform_version, version, - )?), + ) + .await?), v => Err(ProtocolError::UnknownVersionError(format!( - "Unknown IdentityTopUpTransition version for try_from_identity {v}" + "Unknown IdentityTopUpTransition version for try_from_identity_with_signer {v}" ))), } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/v0/mod.rs index d314b9325a7..d20032c799f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/methods/v0/mod.rs @@ -11,8 +11,17 @@ use crate::ProtocolError; use platform_version::version::{FeatureVersion, PlatformVersion}; pub trait IdentityTopUpTransitionMethodsV0 { + /// Build an `IdentityTopUp` state transition whose asset-lock-proof + /// signature is produced from a raw `asset_lock_proof_private_key` held + /// in-process. + /// + /// Prefer [`Self::try_from_identity_with_signer`] when the asset-lock key + /// lives outside Rust (Swift / hardware wallet / HSM): the `_with_signer` + /// variant routes asset-lock signing through an external + /// [`key_wallet::signer::Signer`] so the private key never crosses the + /// FFI boundary as raw bytes. #[cfg(feature = "state-transition-signing")] - fn try_from_identity( + fn try_from_identity_with_private_key( identity: &Identity, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], @@ -21,6 +30,25 @@ pub trait IdentityTopUpTransitionMethodsV0 { version: Option, ) -> Result; + /// Build an `IdentityTopUp` state transition whose asset-lock-proof + /// signature is produced by an external [`key_wallet::signer::Signer`]. + /// + /// The signer atomically derives, signs, and zeroises the key at + /// `asset_lock_proof_path` inside its own trust boundary — the host only + /// sees a 32-byte digest and the resulting Core-ECDSA signature. + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_identity_with_signer( + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + user_fee_increase: UserFeeIncrease, + platform_version: &PlatformVersion, + version: Option, + ) -> Result + where + AS: ::key_wallet::signer::Signer; + /// Get State Transition type fn get_type() -> StateTransitionType { StateTransitionType::IdentityTopUp diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs index da2b8f680b3..ccd4c06edec 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/identity_topup_transition/v0/v0_methods.rs @@ -26,7 +26,7 @@ use crate::version::FeatureVersion; impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransitionV0 { #[cfg(feature = "state-transition-signing")] - fn try_from_identity( + fn try_from_identity_with_private_key( identity: &Identity, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], @@ -50,6 +50,41 @@ impl IdentityTopUpTransitionMethodsV0 for IdentityTopUpTransitionV0 { Ok(state_transition) } + + /// Signer-driven counterpart to [`Self::try_from_identity_with_private_key`]. + /// The asset-lock-proof signature is produced by `asset_lock_signer` via + /// [`StateTransition::sign_with_signer`], which performs the same + /// double-SHA256 + recoverable-compact serialisation as the legacy + /// `dashcore::signer::sign` path — so the resulting state transition is + /// bit-identical on the wire. + #[cfg(all(feature = "state-transition-signing", feature = "core_key_wallet"))] + async fn try_from_identity_with_signer( + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + user_fee_increase: UserFeeIncrease, + _platform_version: &PlatformVersion, + _version: Option, + ) -> Result + where + AS: ::key_wallet::signer::Signer, + { + let identity_top_up_transition = IdentityTopUpTransitionV0 { + asset_lock_proof, + identity_id: identity.id(), + user_fee_increase, + signature: Default::default(), + }; + + let mut state_transition: StateTransition = identity_top_up_transition.into(); + + state_transition + .sign_with_signer(asset_lock_proof_path, asset_lock_signer) + .await?; + + Ok(state_transition) + } } impl IdentityTopUpTransitionAccessorsV0 for IdentityTopUpTransitionV0 { diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 522bba25dd9..1df1e0dfa87 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -2318,7 +2318,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -2516,7 +2516,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -2561,7 +2561,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity, asset_lock_proof_top_up, pk.as_slice(), @@ -2666,7 +2666,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -2711,7 +2711,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity, asset_lock_proof_top_up, pk.as_slice(), @@ -2856,7 +2856,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity, asset_lock_proof_top_up, pk.as_slice(), @@ -2952,7 +2952,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -2997,7 +2997,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity, asset_lock_proof_top_up.clone(), pk.as_slice(), @@ -3068,7 +3068,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof_top_up, pk.as_slice(), @@ -3182,7 +3182,7 @@ mod tests { .into(); let mut identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -3260,7 +3260,7 @@ mod tests { )); let valid_identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -3302,7 +3302,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity, asset_lock_proof_top_up.clone(), pk.as_slice(), @@ -3373,7 +3373,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof_top_up, pk.as_slice(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs index bb7f1a57f9c..d87640b2381 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs @@ -8224,7 +8224,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity_to_fail, asset_lock_proof.clone(), pk.as_slice(), @@ -8440,7 +8440,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs index 3ad20f558cf..a15e1695c74 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs @@ -281,7 +281,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -395,7 +395,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -551,7 +551,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -616,7 +616,7 @@ mod tests { ])); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -775,7 +775,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -840,7 +840,7 @@ mod tests { ])); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -1010,7 +1010,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -1078,7 +1078,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -1246,7 +1246,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -1314,7 +1314,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -1466,7 +1466,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -1556,7 +1556,7 @@ mod tests { ])); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), @@ -1715,7 +1715,7 @@ mod tests { .into(); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof.clone(), pk.as_slice(), @@ -1805,7 +1805,7 @@ mod tests { ])); let identity_create_transition: StateTransition = - IdentityCreateTransition::try_from_identity_with_signer( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, pk.as_slice(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs index db335addf55..ce077429841 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs @@ -202,7 +202,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity_already_in_system, asset_lock_proof, pk.as_slice(), @@ -332,7 +332,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity_already_in_system, asset_lock_proof, pk.as_slice(), @@ -449,7 +449,7 @@ mod tests { ); let identity_top_up_transition: StateTransition = - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( &identity_not_in_system, asset_lock_proof, pk.as_slice(), diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index 82a7374d9d1..0bfee8c03b4 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -2217,7 +2217,7 @@ impl NetworkStrategy { .expect("failed to sign transaction for instant lock"); } - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( identity, asset_lock_proof, secret_key.as_ref(), diff --git a/packages/rs-sdk-ffi/src/identity/put.rs b/packages/rs-sdk-ffi/src/identity/put.rs index 63b3cfaf558..17b16c32229 100644 --- a/packages/rs-sdk-ffi/src/identity/put.rs +++ b/packages/rs-sdk-ffi/src/identity/put.rs @@ -74,7 +74,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_instant_lock( // Use PutIdentity trait to put identity to platform let state_transition = identity - .put_to_platform( + .put_to_platform_with_private_key( &wrapper.sdk, asset_lock_proof, &private_key, @@ -164,7 +164,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_instant_lock_and // Use PutIdentity trait to put identity to platform and wait for response let confirmed_identity = identity - .put_to_platform_and_wait_for_response( + .put_to_platform_and_wait_for_response_with_private_key( &wrapper.sdk, asset_lock_proof, &private_key, @@ -246,7 +246,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_chain_lock( // Use PutIdentity trait to put identity to platform let state_transition = identity - .put_to_platform( + .put_to_platform_with_private_key( &wrapper.sdk, asset_lock_proof, &private_key, @@ -325,7 +325,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_chain_lock_and_w // Use PutIdentity trait to put identity to platform and wait for response let confirmed_identity = identity - .put_to_platform_and_wait_for_response( + .put_to_platform_and_wait_for_response_with_private_key( &wrapper.sdk, asset_lock_proof, &private_key, diff --git a/packages/rs-sdk-ffi/src/identity/topup.rs b/packages/rs-sdk-ffi/src/identity/topup.rs index 8a98c0053d6..80c32eac124 100644 --- a/packages/rs-sdk-ffi/src/identity/topup.rs +++ b/packages/rs-sdk-ffi/src/identity/topup.rs @@ -65,7 +65,7 @@ pub unsafe extern "C" fn dash_sdk_identity_topup_with_instant_lock( use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; let new_balance = identity - .top_up_identity( + .top_up_identity_with_private_key( &wrapper.sdk, asset_lock_proof, &private_key, @@ -139,7 +139,7 @@ pub unsafe extern "C" fn dash_sdk_identity_topup_with_instant_lock_and_wait( use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; let _new_balance = identity - .top_up_identity( + .top_up_identity_with_private_key( &wrapper.sdk, asset_lock_proof, &private_key, diff --git a/packages/rs-sdk/src/platform/transition/broadcast_identity.rs b/packages/rs-sdk/src/platform/transition/broadcast_identity.rs index 7763bb12412..7a3bdf48bb2 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast_identity.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast_identity.rs @@ -87,31 +87,86 @@ pub(crate) trait BroadcastRequestForNewIdentity Result<(StateTransition, BroadcastStateTransitionRequest), Error>; + + /// Signer-driven counterpart to + /// [`Self::broadcast_request_for_new_identity_with_private_key`]. + /// + /// `identity_signer` signs the per-key witnesses on `public_keys[]`, + /// while `asset_lock_signer` produces the outer state-transition ECDSA + /// signature for the key at `asset_lock_proof_path` — atomically + /// deriving, signing, and zeroising inside the signer's trust boundary. + #[cfg(feature = "core_key_wallet")] + #[allow(async_fn_in_trait)] + async fn broadcast_request_for_new_identity_with_signer( + &self, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &S, + platform_version: &PlatformVersion, + ) -> Result<(StateTransition, BroadcastStateTransitionRequest), Error> + where + AS: dpp::key_wallet::signer::Signer + Send + Sync; } impl> BroadcastRequestForNewIdentity for Identity { - async fn broadcast_request_for_new_identity( + async fn broadcast_request_for_new_identity_with_private_key( &self, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &PrivateKey, signer: &S, platform_version: &PlatformVersion, ) -> Result<(StateTransition, BroadcastStateTransitionRequest), Error> { - let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signer( + let identity_create_transition = + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( + self, + asset_lock_proof, + asset_lock_proof_private_key.inner.as_ref(), + signer, + &NativeBlsModule, + 0, + platform_version, + ) + .await?; + ensure_valid_state_transition_structure(&identity_create_transition, platform_version)?; + let request = identity_create_transition.broadcast_request_for_state_transition()?; + Ok((identity_create_transition, request)) + } + + #[cfg(feature = "core_key_wallet")] + async fn broadcast_request_for_new_identity_with_signer( + &self, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &S, + platform_version: &PlatformVersion, + ) -> Result<(StateTransition, BroadcastStateTransitionRequest), Error> + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signers( self, asset_lock_proof, - asset_lock_proof_private_key.inner.as_ref(), - signer, + asset_lock_proof_path, + identity_signer, + asset_lock_signer, &NativeBlsModule, 0, platform_version, diff --git a/packages/rs-sdk/src/platform/transition/put_identity.rs b/packages/rs-sdk/src/platform/transition/put_identity.rs index fd5803c2c90..cb2ea7535e2 100644 --- a/packages/rs-sdk/src/platform/transition/put_identity.rs +++ b/packages/rs-sdk/src/platform/transition/put_identity.rs @@ -26,8 +26,15 @@ use std::collections::{BTreeMap, BTreeSet}; /// Trait for creating identities on the platform. #[async_trait::async_trait] pub trait PutIdentity>: Waitable { - /// Creates an identity using an asset lock proof. - async fn put_to_platform( + /// Creates an identity using an asset lock proof whose private key + /// is held in-process. + /// + /// Prefer [`Self::put_to_platform_with_signer`] when the asset-lock + /// private key lives outside Rust (Swift / hardware wallet / HSM): + /// the `_with_signer` variant routes asset-lock signing through an + /// external [`key_wallet::signer::Signer`] so the private key never + /// crosses the FFI boundary as raw bytes. + async fn put_to_platform_with_private_key( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -37,7 +44,10 @@ pub trait PutIdentity>: Waitable { ) -> Result; /// Creates an identity using an asset lock and waits for confirmation. - async fn put_to_platform_and_wait_for_response( + /// + /// In-process private-key counterpart to + /// [`Self::put_to_platform_and_wait_for_response_with_signer`]. + async fn put_to_platform_and_wait_for_response_with_private_key( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -48,6 +58,48 @@ pub trait PutIdentity>: Waitable { where Self: Sized; + /// Creates an identity using an asset lock proof whose private key + /// is held by an external [`key_wallet::signer::Signer`] (Swift, + /// hardware wallet, HSM). + /// + /// `identity_signer` signs the per-key witnesses on `public_keys[]` + /// (same as [`Self::put_to_platform_with_private_key`]), while + /// `asset_lock_signer` produces the outer state-transition ECDSA + /// signature for the key at `asset_lock_proof_path` — atomically + /// deriving, signing, and zeroising inside the signer's trust + /// boundary. + #[cfg(feature = "core_key_wallet")] + async fn put_to_platform_with_signer( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &IS, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync; + + /// Creates an identity using an asset-lock signer and waits for + /// confirmation. + /// + /// Signer-driven counterpart to + /// [`Self::put_to_platform_and_wait_for_response_with_private_key`]. + #[cfg(feature = "core_key_wallet")] + async fn put_to_platform_and_wait_for_response_with_signer( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &IS, + settings: Option, + ) -> Result + where + Self: Sized, + AS: dpp::key_wallet::signer::Signer + Send + Sync; + /// Creates an identity funded by Platform addresses using explicit nonces. /// /// Use [Identity::new_with_input_addresses_and_keys](dpp::identity::Identity::new_with_input_addresses_and_keys) @@ -91,7 +143,7 @@ pub trait PutIdentity>: Waitable { #[async_trait::async_trait] impl> PutIdentity for Identity { - async fn put_to_platform( + async fn put_to_platform_with_private_key( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -99,7 +151,7 @@ impl> PutIdentity for Identity { signer: &IS, settings: Option, ) -> Result { - put_identity_with_asset_lock( + put_identity_with_asset_lock_and_private_key( self, sdk, asset_lock_proof, @@ -110,7 +162,7 @@ impl> PutIdentity for Identity { .await } - async fn put_to_platform_and_wait_for_response( + async fn put_to_platform_and_wait_for_response_with_private_key( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -119,7 +171,7 @@ impl> PutIdentity for Identity { settings: Option, ) -> Result { let state_transition = self - .put_to_platform( + .put_to_platform_with_private_key( sdk, asset_lock_proof, asset_lock_proof_private_key, @@ -131,6 +183,58 @@ impl> PutIdentity for Identity { Self::wait_for_response(sdk, state_transition, settings).await } + #[cfg(feature = "core_key_wallet")] + async fn put_to_platform_with_signer( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &IS, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + put_identity_with_asset_lock_and_signer( + self, + sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + identity_signer, + settings, + ) + .await + } + + #[cfg(feature = "core_key_wallet")] + async fn put_to_platform_and_wait_for_response_with_signer( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &IS, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + let state_transition = self + .put_to_platform_with_signer( + sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + identity_signer, + settings, + ) + .await?; + + Self::wait_for_response(sdk, state_transition, settings).await + } + async fn put_with_address_funding + Send + Sync>( &self, sdk: &Sdk, @@ -178,7 +282,7 @@ impl> PutIdentity for Identity { } } -async fn put_identity_with_asset_lock>( +async fn put_identity_with_asset_lock_and_private_key>( identity: &Identity, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -187,7 +291,7 @@ async fn put_identity_with_asset_lock>( settings: Option, ) -> Result { let (state_transition, _) = identity - .broadcast_request_for_new_identity( + .broadcast_request_for_new_identity_with_private_key( asset_lock_proof, asset_lock_proof_private_key, signer, @@ -199,6 +303,35 @@ async fn put_identity_with_asset_lock>( Ok(state_transition) } +#[cfg(feature = "core_key_wallet")] +#[allow(clippy::too_many_arguments)] +async fn put_identity_with_asset_lock_and_signer( + identity: &Identity, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + identity_signer: &IS, + settings: Option, +) -> Result +where + IS: Signer, + AS: dpp::key_wallet::signer::Signer + Send + Sync, +{ + let (state_transition, _) = identity + .broadcast_request_for_new_identity_with_signer( + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + identity_signer, + sdk.version(), + ) + .await?; + ensure_valid_state_transition_structure(&state_transition, sdk.version())?; + state_transition.broadcast(sdk, settings).await?; + Ok(state_transition) +} + async fn put_identity_with_address_funding< IS: Signer, AS: Signer, diff --git a/packages/rs-sdk/src/platform/transition/top_up_identity.rs b/packages/rs-sdk/src/platform/transition/top_up_identity.rs index 422c20c4553..b2a041a682b 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_identity.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_identity.rs @@ -11,7 +11,15 @@ use dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; #[async_trait::async_trait] pub trait TopUpIdentity: Waitable { - async fn top_up_identity( + /// Tops up an existing identity using an asset lock proof whose + /// private key is held in-process. + /// + /// Prefer [`Self::top_up_identity_with_signer`] when the asset-lock + /// private key lives outside Rust (Swift / hardware wallet / HSM): + /// the `_with_signer` variant routes asset-lock signing through an + /// external [`key_wallet::signer::Signer`] so the private key never + /// crosses the FFI boundary as raw bytes. + async fn top_up_identity_with_private_key( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -19,11 +27,31 @@ pub trait TopUpIdentity: Waitable { user_fee_increase: Option, settings: Option, ) -> Result; + + /// Tops up an existing identity using an asset-lock signer. + /// + /// Signer-driven counterpart to + /// [`Self::top_up_identity_with_private_key`]. `asset_lock_signer` + /// produces the outer state-transition ECDSA signature for the key + /// at `asset_lock_proof_path` — atomically deriving, signing, and + /// zeroising inside the signer's trust boundary. + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_with_signer( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + user_fee_increase: Option, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync; } #[async_trait::async_trait] impl TopUpIdentity for Identity { - async fn top_up_identity( + async fn top_up_identity_with_private_key( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -31,7 +59,7 @@ impl TopUpIdentity for Identity { user_fee_increase: Option, settings: Option, ) -> Result { - let state_transition = IdentityTopUpTransition::try_from_identity( + let state_transition = IdentityTopUpTransition::try_from_identity_with_private_key( self, asset_lock_proof, asset_lock_proof_private_key.inner.as_ref(), @@ -46,4 +74,35 @@ impl TopUpIdentity for Identity { .balance .ok_or(Error::Generic("expected an identity balance".to_string())) } + + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_with_signer( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + user_fee_increase: Option, + settings: Option, + ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + let state_transition = IdentityTopUpTransition::try_from_identity_with_signer( + self, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + user_fee_increase.unwrap_or_default(), + sdk.version(), + None, + ) + .await?; + ensure_valid_state_transition_structure(&state_transition, sdk.version())?; + let identity: PartialIdentity = state_transition.broadcast_and_wait(sdk, settings).await?; + + identity + .balance + .ok_or(Error::Generic("expected an identity balance".to_string())) + } } diff --git a/packages/strategy-tests/src/transitions.rs b/packages/strategy-tests/src/transitions.rs index fecb5a66aff..34c0111dfe2 100644 --- a/packages/strategy-tests/src/transitions.rs +++ b/packages/strategy-tests/src/transitions.rs @@ -383,7 +383,7 @@ pub fn create_identity_top_up_transition( let (asset_lock_proof, private_key) = proof_and_pk; let pk_bytes = private_key.to_bytes(); - IdentityTopUpTransition::try_from_identity( + IdentityTopUpTransition::try_from_identity_with_private_key( identity, asset_lock_proof, pk_bytes.as_ref(), @@ -1139,7 +1139,7 @@ pub async fn create_identities_state_transitions( if let Some(proof_and_pk) = asset_lock_proofs.pop() { let (asset_lock_proof, private_key) = proof_and_pk; let pk = private_key.to_bytes(); - match IdentityCreateTransition::try_from_identity_with_signer( + match IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity, asset_lock_proof, &pk, @@ -1227,7 +1227,7 @@ where amount_range, rng, ); - let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signer( + let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity.clone(), asset_lock_proof, &pk, @@ -1276,7 +1276,7 @@ pub async fn create_state_transitions_for_identities_and_proofs( ) -> Vec<(Identity, StateTransition)> { let mut results = Vec::with_capacity(identities_with_proofs.len()); for (mut identity, private_key, asset_lock_proof) in identities_with_proofs.into_iter() { - let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signer( + let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signer_and_private_key( &identity.clone(), asset_lock_proof, &private_key, diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index edf90726745..118b898d93a 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -114,7 +114,7 @@ impl WasmSdk { // Put identity to platform and wait identity - .put_to_platform_and_wait_for_response( + .put_to_platform_and_wait_for_response_with_private_key( self.inner_sdk(), asset_lock_proof, &asset_lock_private_key, @@ -203,7 +203,7 @@ impl WasmSdk { // Top up the identity let new_balance = identity - .top_up_identity( + .top_up_identity_with_private_key( self.inner_sdk(), asset_lock_proof, &asset_lock_private_key, From 203067259b3f2164de67b3aa2495c423438e27a9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 00:19:00 +0700 Subject: [PATCH 02/54] feat(platform-wallet-ffi): add MnemonicResolverCoreSigner trampoline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Rust struct implementing `key_wallet::signer::Signer` (Core ECDSA) by wrapping the existing `MnemonicResolverHandle` callback into iOS Keychain. Per signing call: resolve mnemonic via the resolver vtable, derive Core priv key at the requested derivation path, sign the 32-byte digest, zero all intermediate buffers via `Zeroizing<>`, return `(secp256k1::ecdsa::Signature, secp256k1::PublicKey)`. No private keys ever cross the FFI boundary — only signatures and public keys. Lifetime of the resolver handle is the caller's responsibility (documented at the constructor); current call sites keep it alive on the FFI-frame stack. Wraps and reuses the same primitive that the existing `dash_sdk_sign_with_mnemonic_resolver_and_path` FFI uses for Platform-address signing, so the Core-side and Platform-side signers share one architectural pattern and one mnemonic-resolution path. Typed `MnemonicResolverSignerError` enum with 9 variants gives callers structured failure classification (NullHandle, NotFound, BufferTooSmall, ResolverFailed(i32), InvalidUtf8, InvalidMnemonic, DerivationFailed, InvalidScalar, …) instead of stringified blobs. 5 round-trip unit tests cover the happy path, error surfacing, pubkey-vs-signature consistency, null/missing-handle handling, and `SignerMethod::Digest`-only capability. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + packages/rs-platform-wallet-ffi/Cargo.toml | 14 +- packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../src/mnemonic_resolver_core_signer.rs | 472 ++++++++++++++++++ 4 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs diff --git a/Cargo.lock b/Cargo.lock index b6294fe31f5..ec7d119042d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4881,6 +4881,7 @@ name = "platform-wallet-ffi" version = "2.1.1" dependencies = [ "anyhow", + "async-trait", "bincode", "bs58", "cbindgen 0.27.0", @@ -4897,6 +4898,7 @@ dependencies = [ "rs-sdk-ffi", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", "tracing", "zeroize", diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index db2a2ebb31e..b0d8844e802 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [dependencies] platform-wallet = { path = "../rs-platform-wallet" } dpp = { path = "../rs-dpp" } -dash-sdk = { path = "../rs-sdk" } +dash-sdk = { path = "../rs-sdk", features = ["wallet"] } # Needed for `SignerHandle` + `VTableSigner` so the `*_with_signer` # entry points can accept iOS-side keychain-backed signers without # duplicating the vtable plumbing. See `signer.rs` in rs-sdk-ffi. @@ -54,6 +54,18 @@ bs58 = "0.5" # Zeroize intermediate key material crossing the FFI boundary. zeroize = { version = "1", features = ["derive"] } +# Needed for the `#[async_trait]` macro on +# `MnemonicResolverCoreSigner`'s `Signer` impl. The upstream +# `key_wallet::signer::Signer` trait is `#[async_trait]`-shaped, so +# our impl has to match the same desugaring for the lifetimes to line up. +async-trait = "0.1" + +# `MnemonicResolverSignerError` uses `#[derive(thiserror::Error)]` so +# every resolver-side failure mode is a typed enum variant rather than +# a free-form String. Surfaces clean discriminants to callers (FFI + +# rs-dpp's `sign_with_signer` → `ProtocolError::ExternalSignerError`). +thiserror = "2.0" + [dev-dependencies] tempfile = "3.8" dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 0085d8c8547..358354d1eb9 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -43,6 +43,7 @@ pub mod identity_update; pub mod identity_withdrawal; pub mod managed_identity; pub mod manager; +pub mod mnemonic_resolver_core_signer; pub mod manager_diagnostics; pub mod memory_explorer; pub mod persistence; @@ -99,6 +100,7 @@ pub use identity_update::*; pub use identity_withdrawal::*; pub use managed_identity::*; pub use manager::*; +pub use mnemonic_resolver_core_signer::*; pub use manager_diagnostics::*; pub use memory_explorer::*; pub use persistence::*; diff --git a/packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs new file mode 100644 index 00000000000..f0d0315a4e3 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs @@ -0,0 +1,472 @@ +//! Core-side ECDSA [`key_wallet::signer::Signer`] implementation that +//! sources its private keys via the existing +//! [`MnemonicResolverHandle`](crate::MnemonicResolverHandle) callback. +//! +//! Same architectural intent as +//! [`crate::dash_sdk_sign_with_mnemonic_resolver_and_path`] — keep +//! the Swift caller out of the mnemonic-orchestration loop so the +//! `swift-sdk/CLAUDE.md` "no mnemonic round-tripping" rule is +//! satisfied — but exposed as a Rust-side `Signer` trait object so +//! it can plug into key-wallet's signer-driven builders +//! (`build_asset_lock_with_signer`, `TransactionBuilder::build_signed`) +//! and rs-sdk's `_with_signer` state-transition methods. +//! +//! # Lifecycle and unsafety contract +//! +//! - The wrapper stores the resolver pointer as a `usize` and only +//! reconstitutes the typed pointer inside `sign_ecdsa` / `public_key`. +//! That keeps the struct `Send + Sync` without an `unsafe impl` on +//! the field type itself. +//! - The caller is responsible for keeping the resolver handle alive +//! for the lifetime of every `MnemonicResolverCoreSigner` value that +//! wraps it. The signer never destroys the resolver — the FFI entry +//! point that built the signer is also the one that owns the +//! resolver's lifetime; ownership boundaries are documented at the +//! call site. +//! - The MnemonicResolverHandle's vtable is `Send + Sync` by contract +//! (`MnemonicResolverHandle` itself carries `unsafe impl Send + Sync`). +//! +//! # No double-hashing +//! +//! The `Signer::sign_ecdsa` trait contract is explicit (see +//! `key-wallet/src/signer.rs:109-120`): the caller passes a +//! pre-computed 32-byte digest, and the signer signs it directly. +//! rs-dpp's `StateTransition::sign_with_signer` (which is what the +//! `_with_signer` SDK calls eventually invoke) takes care of the +//! `double_sha` pre-image; we just receive the 32 bytes and sign. +//! +//! # Zeroization +//! +//! Every intermediate that carries key material — the resolver +//! buffer, the BIP-39 seed, the derived 32-byte scalar — is wrapped +//! in `Zeroizing` and dropped before the method returns. No private +//! key bytes survive past the trait-method boundary. + +use std::ffi::c_void; +use std::os::raw::c_char; + +use async_trait::async_trait; +use dashcore::secp256k1::{self, Secp256k1}; +use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use key_wallet::signer::{Signer, SignerMethod}; +use key_wallet::Network; +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::derive_and_persist_callbacks::{ + mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, +}; +use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; + +/// Failure modes for the +/// [`MnemonicResolverCoreSigner`](crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner) +/// signer. +/// +/// Replaces the earlier `type Error = String;` shape with discriminants +/// callers can pattern-match (FFI layer, retry policies). The +/// `ResolverFailed(i32)` variant carries the resolver's raw return code +/// for the unknown-error path so operators can inspect the value +/// without modifying this enum every time a new code is introduced. +/// +/// All variants are `Display`-only — no key material ever appears in +/// the error payload (mnemonic / seed / derived scalar are all +/// zeroized before any error can leak out of the resolver flow). +#[derive(Debug, Error)] +pub enum MnemonicResolverSignerError { + /// Resolver handle was null at signer-construction time and the + /// caller subsequently invoked a signing method. Production callers + /// always pass a non-null handle; this variant exists primarily so + /// tests can exercise the null-safety contract documented on + /// [`MnemonicResolverCoreSigner::new`]. + #[error("null mnemonic resolver handle")] + NullHandle, + + /// The Swift-side resolver reported that no mnemonic is stored for + /// the wallet_id this signer was constructed with. Translates the + /// FFI `NOT_FOUND` return code. + #[error("mnemonic not found in keychain for the given wallet_id")] + NotFound, + + /// The resolver requested a longer output buffer than this signer + /// allocates. Should be unreachable in practice — the buffer is + /// sized for the maximum BIP-39 phrase across all supported word + /// lists. Translates the FFI `BUFFER_TOO_SMALL` return code. + #[error("mnemonic resolver buffer too small")] + BufferTooSmall, + + /// Catch-all for unknown resolver return codes. The raw code is + /// preserved so operators can grep the Swift bridge for the + /// matching error path. + #[error("mnemonic resolver failed with code {0}")] + ResolverFailed(i32), + + /// The resolver returned data that wasn't valid UTF-8 over the + /// declared length. Indicates a Swift-side encoding bug. + #[error("invalid UTF-8 in resolved mnemonic")] + InvalidUtf8, + + /// The resolver returned a buffer whose declared length is zero or + /// exceeds the capacity. Indicates a Swift-side framing bug. + #[error("resolver returned invalid mnemonic length {0}")] + InvalidMnemonicLength(usize), + + /// The resolved string is not a valid BIP-39 mnemonic phrase + /// (failed checksum or word-list lookup). + #[error("invalid mnemonic phrase: {0}")] + InvalidMnemonic(String), + + /// BIP-32 derivation failed — either the master key was + /// non-conformant or the path produced an invalid child key. The + /// inner message is the upstream `bip32` library's `Display`. + #[error("BIP-32 derivation failed: {0}")] + DerivationFailed(String), + + /// The derived 32-byte scalar is not a valid secp256k1 field + /// element. Vanishingly improbable in production (the BIP-32 + /// derivation flow already filters non-conformant child keys), but + /// surfaced explicitly so this case can't masquerade as a generic + /// error. + #[error("invalid private key scalar: {0}")] + InvalidScalar(String), +} + +/// `key_wallet::signer::Signer` implementation that derives ECDSA +/// secp256k1 keys from a wallet mnemonic, fetched via a Swift-owned +/// [`MnemonicResolverHandle`]. +/// +/// Every signing operation is atomic: resolve mnemonic → parse → +/// seed → derive at the requested path → sign (or compute pubkey) → +/// zero all intermediate buffers, in one `await`-free synchronous +/// step (the trait method itself is `async` for compatibility with +/// the wider [`Signer`] surface, but the body never yields). +pub struct MnemonicResolverCoreSigner { + /// Resolver handle stored as `usize` so the wrapping struct can + /// derive `Send + Sync` without an extra `unsafe impl` on the + /// field. The pointer is reconstituted inside the methods that + /// need it — see the unsafety contract on the module-level docs + /// for the lifetime guarantee. + resolver_addr: usize, + /// Wallet id passed through to every resolver invocation so the + /// Swift side can look up the right mnemonic. Same shape as + /// `dash_sdk_sign_with_mnemonic_resolver_and_path`'s + /// `wallet_id_bytes` parameter. + wallet_id: [u8; 32], + /// Network the derived `ExtendedPrivKey` is bound to. Captured + /// at construction so the trait methods don't need an extra + /// argument plumbed through key-wallet's `Signer` contract + /// (which is intentionally network-agnostic). + network: Network, +} + +// SAFETY: `usize` and `[u8; 32]` are both `Send + Sync`; `Network` +// is a plain `Copy` enum. The raw resolver pointer hidden inside +// `resolver_addr` is documented to be thread-safe by contract — the +// Swift-side vtable is either `@MainActor`-isolated or backed by a +// serial dispatch queue, mirroring how `MnemonicResolverHandle` +// itself carries `unsafe impl Send + Sync` in +// `derive_and_persist_callbacks.rs:157-158`. +// +// We deliberately do *not* stash the pointer as `*mut +// MnemonicResolverHandle` directly: that field would be `!Send + +// !Sync`, forcing an `unsafe impl Send + Sync` on this struct that +// covers more than the resolver's actual thread-safety contract +// (raw pointer fields are unsoundly broad). The `usize` indirection +// is sound because it sheds the "pointer to T" type, leaving only +// a numeric handle whose dereference responsibility lives in each +// method body (next to its safety justification). +// +// No explicit `unsafe impl Send` / `unsafe impl Sync` needed — both +// auto-traits derive from the `usize + [u8; 32] + Network` field +// shape. + +impl MnemonicResolverCoreSigner { + /// Construct a new `MnemonicResolverCoreSigner`. + /// + /// # Safety + /// - `handle` must come from + /// [`crate::dash_sdk_mnemonic_resolver_create`] and must stay + /// alive for the entire lifetime of every `MnemonicResolverCoreSigner` + /// value that wraps it. The signer never destroys the handle — + /// ownership belongs to the FFI caller that built it. + /// - `handle` may be null; methods will fail with a resolver + /// error if the resolver is dereferenced. Production callers + /// should pass non-null. + pub unsafe fn new( + handle: *mut MnemonicResolverHandle, + wallet_id: [u8; 32], + network: Network, + ) -> Self { + Self { + resolver_addr: handle as usize, + wallet_id, + network, + } + } + + /// Resolve the mnemonic from the Swift-side callback, then + /// derive the secp256k1 private key at `path`. Returns the raw + /// 32-byte scalar in a `Zeroizing` wrapper so the caller's last + /// drop point zeros it. + /// + /// All other intermediate buffers (mnemonic, seed) are dropped + /// (and zeroed) before this method returns — only the final + /// derived scalar leaks out, and even that is `Zeroizing`-wrapped. + fn derive_priv( + &self, + path: &DerivationPath, + ) -> Result, MnemonicResolverSignerError> { + if self.resolver_addr == 0 { + return Err(MnemonicResolverSignerError::NullHandle); + } + + // ---- Resolve mnemonic into a Zeroizing buffer ----------------------- + let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = + Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); + let mut mnemonic_len: usize = 0; + + // SAFETY: We re-cast from `usize` to `*mut MnemonicResolverHandle` + // here. The caller of `new()` guaranteed the original pointer + // outlives this signer (see the unsafety contract on + // `Self::new`). `MnemonicResolverHandle`'s vtable + ctx are + // thread-stable per the same module's `unsafe impl Send + + // Sync` justification. + let resolver = unsafe { &*(self.resolver_addr as *const MnemonicResolverHandle) }; + let vtable = unsafe { &*resolver.vtable }; + let rc = unsafe { + (vtable.resolve)( + resolver.ctx as *const c_void, + self.wallet_id.as_ptr(), + mnemonic_buf.as_mut_ptr() as *mut c_char, + MNEMONIC_RESOLVER_BUFFER_CAPACITY, + &mut mnemonic_len, + ) + }; + match rc { + x if x == mnemonic_resolver_result::SUCCESS => {} + x if x == mnemonic_resolver_result::NOT_FOUND => { + return Err(MnemonicResolverSignerError::NotFound); + } + x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { + return Err(MnemonicResolverSignerError::BufferTooSmall); + } + other => { + return Err(MnemonicResolverSignerError::ResolverFailed(other)); + } + } + if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { + return Err(MnemonicResolverSignerError::InvalidMnemonicLength( + mnemonic_len, + )); + } + + // Parse mnemonic. UTF-8 validation runs on the prefix only — + // we never construct an owned `String` (the resulting buffer + // is dropped via Zeroizing). + let mnemonic_str = std::str::from_utf8(&mnemonic_buf[..mnemonic_len]) + .map_err(|_| MnemonicResolverSignerError::InvalidUtf8)?; + let mnemonic = parse_mnemonic_any_language(mnemonic_str) + .map_err(|e| MnemonicResolverSignerError::InvalidMnemonic(e.to_string()))?; + + // ---- Derive seed and BIP-32 key at `path` --------------------------- + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + drop(mnemonic); + + let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")) + })?; + let derived = master.derive_priv(&secp, path).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")) + })?; + + // `secret_bytes()` returns a plain `[u8; 32]`; wrap in + // `Zeroizing` so the caller (and any panic-unwind path) + // wipes it on drop. + let bytes = Zeroizing::new(derived.private_key.secret_bytes()); + Ok(bytes) + } +} + +#[async_trait] +impl Signer for MnemonicResolverCoreSigner { + type Error = MnemonicResolverSignerError; + + fn supported_methods(&self) -> &[SignerMethod] { + // Only digest signing is supported — the resolver flow has + // no facility for parsing or rendering full transactions. + static METHODS: &[SignerMethod] = &[SignerMethod::Digest]; + METHODS + } + + async fn sign_ecdsa( + &self, + path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(secp256k1::ecdsa::Signature, secp256k1::PublicKey), Self::Error> { + let secret_bytes = self.derive_priv(path)?; + let secp = Secp256k1::new(); + // `SecretKey::from_slice` validates the 32-byte scalar is a + // legitimate field element. The slice borrow is dropped at + // the end of this block; `secret_bytes` is then zeroed when + // it falls out of scope. + let secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) + .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; + let msg = secp256k1::Message::from_digest(sighash); + let signature = secp.sign_ecdsa(&msg, &secret); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); + // `secp256k1::SecretKey` is `Copy` (a thin wrapper over a + // 32-byte buffer) and doesn't itself zero on drop — but the + // backing buffer here came from `secret_bytes` + // (a `Zeroizing<[u8; 32]>`), which IS wiped when it falls + // out of scope below. The `secret` binding is forgotten by + // letting it go out of scope; no explicit `drop` needed. + let _ = secret; + Ok((signature, pubkey)) + } + + async fn public_key(&self, path: &DerivationPath) -> Result { + let secret_bytes = self.derive_priv(path)?; + let secp = Secp256k1::new(); + let secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) + .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); + let _ = secret; + Ok(pubkey) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive_and_persist_callbacks::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + }; + use std::str::FromStr; + + /// English BIP-39 test vector (all-zero entropy). + const ENGLISH_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + unsafe extern "C" fn english_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + let phrase = ENGLISH_PHRASE.as_bytes(); + if phrase.len() + 1 > out_capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn missing_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + ) -> i32 { + mnemonic_resolver_result::NOT_FOUND + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + fn make_resolver( + cb: crate::derive_and_persist_callbacks::MnemonicResolveCallback, + ) -> *mut MnemonicResolverHandle { + unsafe { dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), cb, noop_destroy) } + } + + fn test_path() -> DerivationPath { + DerivationPath::from_str("m/9'/1'/5'/0'/0'/0'/0'").expect("valid path") + } + + #[tokio::test] + async fn sign_ecdsa_round_trips_and_verifies() { + let resolver = make_resolver(english_resolve); + let signer = unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let sighash = [0x42u8; 32]; + let (sig, pk) = signer + .sign_ecdsa(&test_path(), sighash) + .await + .expect("signing succeeds"); + + let secp = Secp256k1::new(); + let msg = secp256k1::Message::from_digest(sighash); + secp.verify_ecdsa(&msg, &sig, &pk) + .expect("signature must verify against returned pubkey"); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[tokio::test] + async fn public_key_matches_sign_ecdsa_pubkey() { + let resolver = make_resolver(english_resolve); + let signer = unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let path = test_path(); + let pk_only = signer.public_key(&path).await.expect("public_key succeeds"); + let (_, pk_via_sign) = signer + .sign_ecdsa(&path, [0u8; 32]) + .await + .expect("signing succeeds"); + + assert_eq!( + pk_only, pk_via_sign, + "public_key() and sign_ecdsa() must return the same pubkey for the same path" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[tokio::test] + async fn missing_resolver_surfaces_not_found_error() { + let resolver = make_resolver(missing_resolve); + let signer = unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .sign_ecdsa(&test_path(), [0u8; 32]) + .await + .expect_err("must fail when resolver returns NOT_FOUND"); + // Pin the typed variant — String-contains is no longer the + // contract. Surfaces FFI/test breakage at the structured-error + // boundary rather than at a free-form Display payload. + assert!( + matches!(err, MnemonicResolverSignerError::NotFound), + "error should be NotFound, got: {err:?}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[tokio::test] + async fn null_handle_surfaces_clean_error() { + let signer = unsafe { + MnemonicResolverCoreSigner::new(std::ptr::null_mut(), [0u8; 32], Network::Testnet) + }; + let err = signer + .sign_ecdsa(&test_path(), [0u8; 32]) + .await + .expect_err("must fail with null handle"); + assert!( + matches!(err, MnemonicResolverSignerError::NullHandle), + "error should be NullHandle, got: {err:?}" + ); + } + + #[test] + fn advertises_digest_only() { + let signer = unsafe { + MnemonicResolverCoreSigner::new(std::ptr::null_mut(), [0u8; 32], Network::Testnet) + }; + let methods = signer.supported_methods(); + assert_eq!(methods.len(), 1); + assert!(matches!(methods[0], SignerMethod::Digest)); + } +} From f1a7d1c26238bca07a0b56a205a2feaa8a267ce6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 00:21:07 +0700 Subject: [PATCH 03/54] =?UTF-8?q?refactor(rs-platform-wallet):=20unify=20L?= =?UTF-8?q?1/L2=20with=20IS=E2=86=92CL=20fallback=20+=20ExternalSignable?= =?UTF-8?q?=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the dual register/top-up paths (legacy-vs-funded) into a single L1 (signer-only) + L2 (funding+cleanup) pair, and wires ExternalSignable wallets end-to-end: - types/funding.rs: `IdentityFunding` enum (`FromWalletBalance`, `FromExistingAssetLock`, `UseAssetLock { proof, derivation_path }`) replaces `IdentityFundingMethod`/`TopUpFundingMethod`. - asset_lock/build.rs: `build_asset_lock_transaction` and `create_funded_asset_lock_proof` now take a Core signer and return `(_, DerivationPath)` — credit-output private key no longer leaves the wallet. - identity/network/registration.rs: - L1 `register_identity_with_signer(keys_map, proof, path, …)` - L2 `register_identity_with_funding(IdentityFunding, …)` — builds asset lock, awaits IS-lock with 180s timeout, falls back to chainlock proof on timeout, removes the tracked asset lock after a successful registration (H3 cleanup). - `resolve_funding_with_is_timeout_fallback` helper centralises the IS→CL transition. - identity/network/top_up.rs: mirror split for top-up. - error.rs: `is_instant_lock_timeout` discriminator. FFI (`rs-platform-wallet-ffi`): - `identity_registration_funded_with_signer` now drives `register_identity_with_funding(FromWalletBalance{…})` and accepts a `MnemonicResolverHandle` for Core ECDSA signing. - `asset_lock/build.rs`, `asset_lock/sync.rs`, `core_wallet/broadcast.rs` pass the resolver-backed signer through every path that previously required a root extended privkey. Result: ExternalSignable wallets can register/top-up identities without ever materialising the root xpriv or credit-output key on the Rust side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/asset_lock/build.rs | 90 +- .../src/asset_lock/sync.rs | 22 +- .../src/core_wallet/broadcast.rs | 42 +- ...dentity_registration_funded_with_signer.rs | 60 +- packages/rs-platform-wallet/Cargo.toml | 4 +- packages/rs-platform-wallet/src/error.rs | 17 + packages/rs-platform-wallet/src/lib.rs | 5 +- .../src/wallet/asset_lock/build.rs | 99 +- .../src/wallet/asset_lock/manager.rs | 15 + .../src/wallet/asset_lock/sync/recovery.rs | 56 +- .../src/wallet/core/broadcast.rs | 27 +- .../src/wallet/core/wallet.rs | 8 + .../src/wallet/identity/mod.rs | 4 +- .../wallet/identity/network/registration.rs | 976 ++++++++++++------ .../src/wallet/identity/network/top_up.rs | 198 +--- .../src/wallet/identity/types/funding.rs | 119 +-- .../src/wallet/identity/types/mod.rs | 2 +- 17 files changed, 1089 insertions(+), 655 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs index 1bb8c5fe286..b50c0ec55b1 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/build.rs @@ -1,45 +1,85 @@ //! FFI bindings for asset lock transaction building. - +//! +//! These entry points are signer-driven: the asset-lock build path +//! never sees a raw credit-output private key. Instead the caller +//! supplies a [`MnemonicResolverHandle`] (the same vtable used by +//! `dash_sdk_sign_with_mnemonic_resolver_and_path`); the FFI wraps +//! it in a [`MnemonicResolverCoreSigner`] for the lifetime of the +//! call and returns the credit-output derivation path as a C +//! string. Callers later hand the path back to the same resolver +//! when consuming the asset lock on Platform. + +use crate::derive_and_persist_callbacks::MnemonicResolverHandle; use crate::error::*; use crate::handle::*; +use crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use std::ffi::CString; +use std::os::raw::c_char; -/// Build an asset lock transaction. +/// Build an asset lock transaction via an external mnemonic resolver. /// /// On success: /// - `out_tx_bytes`/`out_tx_len`: serialized signed transaction -/// - `out_private_key`: 32-byte one-time private key +/// - `out_derivation_path`: NUL-terminated C string with the +/// credit-output derivation path (e.g. `m/9'/1'/5'/0'/0'/3'/0'`). +/// Free with `platform_wallet_string_free`. /// /// Free tx bytes with `asset_lock_manager_free_tx_bytes`. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains +/// ownership; this function does NOT destroy it. #[no_mangle] +#[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn asset_lock_manager_build_transaction( handle: Handle, amount_duffs: u64, account_index: u32, funding_type: u32, identity_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, out_tx_bytes: *mut *mut u8, out_tx_len: *mut usize, - out_private_key: *mut [u8; 32], + out_derivation_path: *mut *mut c_char, ) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); check_ptr!(out_tx_bytes); check_ptr!(out_tx_len); - check_ptr!(out_private_key); + check_ptr!(out_derivation_path); let option = parse_funding_type(funding_type); let funding = unwrap_option_or_return!(option); + let signer_addr = core_signer_handle as usize; + let option = ASSET_LOCK_MANAGER_STORAGE.with_item(handle, |manager| { + let wallet_id = manager.wallet_id(); + let network = manager.network(); + // SAFETY: `signer_addr` came from `core_signer_handle` which + // the caller pinned alive for this call (see fn-level safety + // doc). The `MnemonicResolverCoreSigner` lives only on this + // stack frame and is dropped before the function returns. + let signer = unsafe { + MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; runtime().block_on(manager.build_asset_lock_transaction( amount_duffs, account_index, funding, identity_index, + &signer, )) }); let result = unwrap_option_or_return!(option); - let (tx, key) = unwrap_result_or_return!(result); + let (tx, path) = unwrap_result_or_return!(result); let serialized = dashcore::consensus::serialize(&tx); let len = serialized.len(); @@ -47,19 +87,28 @@ pub unsafe extern "C" fn asset_lock_manager_build_transaction( *out_tx_bytes = Box::into_raw(boxed) as *mut u8; *out_tx_len = len; - *out_private_key = key.inner.secret_bytes(); + let path_c = unwrap_result_or_return!(CString::new(path.to_string())); + *out_derivation_path = path_c.into_raw(); PlatformWalletFFIResult::ok() } -/// Build, broadcast, and wait for an asset lock proof. +/// Build, broadcast, and wait for an asset lock proof via an +/// external mnemonic resolver. /// /// On success: /// - `out_proof_bytes`/`out_proof_len`: bincode-encoded AssetLockProof -/// - `out_private_key`: 32-byte one-time private key +/// - `out_derivation_path`: NUL-terminated C string with the +/// credit-output derivation path. Free with +/// `platform_wallet_string_free`. /// - `out_txid`: 32-byte transaction ID /// /// Free proof bytes with `asset_lock_manager_free_proof_bytes`. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`. Ownership is retained by the +/// caller. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn asset_lock_manager_create_funded_proof( @@ -68,28 +117,42 @@ pub unsafe extern "C" fn asset_lock_manager_create_funded_proof( account_index: u32, funding_type: u32, identity_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, out_proof_bytes: *mut *mut u8, out_proof_len: *mut usize, - out_private_key: *mut [u8; 32], + out_derivation_path: *mut *mut c_char, out_txid: *mut [u8; 32], ) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); check_ptr!(out_proof_bytes); check_ptr!(out_proof_len); - check_ptr!(out_private_key); + check_ptr!(out_derivation_path); check_ptr!(out_txid); let funding = unwrap_option_or_return!(parse_funding_type(funding_type)); + let signer_addr = core_signer_handle as usize; + let option = ASSET_LOCK_MANAGER_STORAGE.with_item(handle, |manager| { + let wallet_id = manager.wallet_id(); + let network = manager.network(); + let signer = unsafe { + MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; runtime().block_on(manager.create_funded_asset_lock_proof( amount_duffs, account_index, funding, identity_index, + &signer, )) }); let result = unwrap_option_or_return!(option); - let (proof, key, out_point) = unwrap_result_or_return!(result); + let (proof, path, out_point) = unwrap_result_or_return!(result); let bytes = unwrap_result_or_return!(dpp::bincode::encode_to_vec( &proof, @@ -100,7 +163,8 @@ pub unsafe extern "C" fn asset_lock_manager_create_funded_proof( let boxed = bytes.into_boxed_slice(); *out_proof_bytes = Box::into_raw(boxed) as *mut u8; *out_proof_len = len; - *out_private_key = key.inner.secret_bytes(); + let path_c = unwrap_result_or_return!(CString::new(path.to_string())); + *out_derivation_path = path_c.into_raw(); let mut txid_bytes = [0u8; 32]; txid_bytes.copy_from_slice(&out_point.txid[..]); *out_txid = txid_bytes; diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs index 9ecc4069ed1..4c64d863716 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs @@ -4,6 +4,8 @@ use crate::error::*; use crate::handle::*; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use std::ffi::CString; +use std::os::raw::c_char; use std::time::Duration; fn parse_outpoint(txid: *const [u8; 32], vout: u32) -> dashcore::OutPoint { @@ -19,9 +21,18 @@ fn parse_outpoint(txid: *const [u8; 32], vout: u32) -> dashcore::OutPoint { /// /// On success: /// - `out_proof_bytes`/`out_proof_len`: bincode-encoded AssetLockProof -/// - `out_private_key`: 32-byte one-time private key +/// - `out_derivation_path`: NUL-terminated C string with the +/// credit-output derivation path (free with +/// `platform_wallet_string_free`). /// /// Free proof bytes with `asset_lock_manager_free_proof_bytes`. +/// +/// Unlike `asset_lock_manager_create_funded_proof`, this entry point +/// does **not** take a core signer handle — the resume path only +/// re-derives the proof and the credit-output derivation path from +/// the already-tracked lock state; signing the consume transition is +/// the next stage's responsibility (e.g. +/// [`crate::platform_wallet_register_identity_with_funding_signer`]). #[no_mangle] pub unsafe extern "C" fn asset_lock_manager_resume( handle: Handle, @@ -30,12 +41,12 @@ pub unsafe extern "C" fn asset_lock_manager_resume( timeout_secs: u64, out_proof_bytes: *mut *mut u8, out_proof_len: *mut usize, - out_private_key: *mut [u8; 32], + out_derivation_path: *mut *mut c_char, ) -> PlatformWalletFFIResult { check_ptr!(txid); check_ptr!(out_proof_bytes); check_ptr!(out_proof_len); - check_ptr!(out_private_key); + check_ptr!(out_derivation_path); let out_point = parse_outpoint(txid, vout); let timeout = Duration::from_secs(timeout_secs); @@ -44,7 +55,7 @@ pub unsafe extern "C" fn asset_lock_manager_resume( runtime().block_on(manager.resume_asset_lock(&out_point, timeout)) }); let result = unwrap_option_or_return!(option); - let (proof, key) = unwrap_result_or_return!(result); + let (proof, path) = unwrap_result_or_return!(result); let bytes = unwrap_result_or_return!(dpp::bincode::encode_to_vec( &proof, dpp::bincode::config::standard() @@ -53,7 +64,8 @@ pub unsafe extern "C" fn asset_lock_manager_resume( let boxed = bytes.into_boxed_slice(); *out_proof_bytes = Box::into_raw(boxed) as *mut u8; *out_proof_len = len; - *out_private_key = key.inner.secret_bytes(); + let path_c = unwrap_result_or_return!(CString::new(path.to_string())); + *out_derivation_path = path_c.into_raw(); PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 8a2cafa5912..8cb4b16ff7a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -1,7 +1,9 @@ //! FFI bindings for CoreWallet transaction building and broadcasting. +use crate::derive_and_persist_callbacks::MnemonicResolverHandle; use crate::error::*; use crate::handle::*; +use crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; use std::os::raw::c_char; @@ -32,7 +34,21 @@ pub unsafe extern "C" fn core_wallet_broadcast_transaction( PlatformWalletFFIResult::ok() } -/// Build, sign, and broadcast a payment to the given addresses. +/// Build, sign, and broadcast a payment to the given addresses via +/// an external mnemonic-resolver-backed signer. +/// +/// The Swift caller supplies a [`MnemonicResolverHandle`] — the same +/// vtable shape used by +/// [`crate::dash_sdk_sign_with_mnemonic_resolver_and_path`] — which +/// the FFI wraps in a [`MnemonicResolverCoreSigner`] for the +/// lifetime of this call. Private keys never cross the FFI as raw +/// bytes; every signature is produced inside the signer's atomic +/// derive-and-sign step. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`. Ownership is retained by the +/// caller — this function does NOT destroy it. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn core_wallet_send_to_addresses( @@ -42,9 +58,11 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses( addresses: *const *const c_char, amounts: *const u64, count: usize, + core_signer_handle: *mut MnemonicResolverHandle, out_tx_bytes: *mut *mut u8, out_tx_len: *mut usize, ) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); if count > 0 { check_ptr!(addresses); check_ptr!(amounts); @@ -76,8 +94,28 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses( } }; + let signer_addr = core_signer_handle as usize; + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { - runtime().block_on(wallet.send_to_addresses(std_account_type, account_index, outputs)) + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: the resolver handle is pinned alive for the duration + // of this FFI call (see fn-level safety doc). The + // `MnemonicResolverCoreSigner` lives on this stack frame and + // is dropped before the function returns. + let signer = unsafe { + MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + runtime().block_on(wallet.send_to_addresses( + std_account_type, + account_index, + outputs, + &signer, + )) }); let result = unwrap_option_or_return!(option); let tx = unwrap_result_or_return!(result); diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index c7a12adbd0a..a635d7aae97 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -1,5 +1,19 @@ //! Asset-lock-funded identity registration driven by an external -//! `SignerHandle`. +//! `SignerHandle` (for the per-identity-key signatures) and a +//! `MnemonicResolverHandle` (for the Core-side asset-lock signature). +//! +//! Two signer surfaces are deliberately distinct: +//! +//! - `signer_handle` (a `*mut rs_sdk_ffi::SignerHandle`) is the +//! Platform-side per-identity-key signer. It produces the `BLS` or +//! `ECDSA` signatures over the IdentityCreate transition's +//! per-public-key witnesses. +//! - `core_signer_handle` (a `*mut MnemonicResolverHandle`) is the +//! Core-side ECDSA signer used for the asset-lock's outer +//! state-transition signature. It reuses the existing +//! Keychain-resolver vtable so the credit-output private key never +//! crosses the FFI boundary as raw bytes — see +//! [`crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner`]. use std::collections::BTreeMap; use std::convert::TryFrom; @@ -9,17 +23,28 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; -use platform_wallet::wallet::identity::types::funding::IdentityFundingMethod; +use platform_wallet::wallet::identity::types::funding::IdentityFunding; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; +use crate::derive_and_persist_callbacks::MnemonicResolverHandle; use crate::error::*; use crate::handle::*; use crate::identity_registration_with_signer::IdentityPubkeyFFI; +use crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner; use crate::runtime::block_on_worker; use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// Register a new asset-lock-funded identity using an external signer. +/// +/// # Safety +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`. The caller retains +/// ownership. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains +/// ownership. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( @@ -29,10 +54,12 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( identity_pubkeys: *const IdentityPubkeyFFI, identity_pubkeys_count: usize, signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, out_identity_id: *mut [u8; 32], out_identity_handle: *mut Handle, ) -> PlatformWalletFFIResult { check_ptr!(signer_handle); + check_ptr!(core_signer_handle); check_ptr!(identity_pubkeys); check_ptr!(out_identity_id); check_ptr!(out_identity_handle); @@ -73,18 +100,39 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( ); } + // Round-trip both handles through `usize` so the spawned future's + // capture is `Send + 'static` — same pattern used by the existing + // address-signer FFI (raw pointers are `!Send`, `usize` isn't). let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + // Capture the network the asset-lock signer should derive + // under. Pulled from the wallet itself rather than threaded + // as an extra FFI parameter — it would be ambiguous if the + // two disagreed. + let network = wallet.sdk().network; block_on_worker(async move { - let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + // SAFETY: see the fn-level safety doc — both handles are + // pinned alive for the duration of this FFI call. + let identity_signer: &VTableSigner = + unsafe { &*(signer_addr as *const VTableSigner) }; + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; identity_wallet - .register_identity_with_funding_external_signer( - IdentityFundingMethod::FundWithWallet { amount_duffs }, + .register_identity_with_funding( + IdentityFunding::FromWalletBalance { amount_duffs }, identity_index, keys_map, - signer, + identity_signer, + &asset_lock_signer, None, ) .await diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 9d9bdae0377..ddf1f0a2187 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -9,7 +9,7 @@ description = "Platform wallet with identity management support" [dependencies] # Dash Platform packages dpp = { path = "../rs-dpp" } -dash-sdk = { path = "../rs-sdk", default-features = false, features = ["dashpay-contract", "dpns-contract"] } +dash-sdk = { path = "../rs-sdk", default-features = false, features = ["dashpay-contract", "dpns-contract", "wallet"] } platform-encryption = { path = "../rs-platform-encryption" } # Key wallet dependencies (from rust-dashcore) @@ -57,7 +57,7 @@ static_assertions = "1.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Re-enable the SDK with mocks feature for test-only mock builders; # the non-test build keeps the leaner default-feature SDK above. -dash-sdk = { path = "../rs-sdk", default-features = false, features = ["dashpay-contract", "dpns-contract", "mocks"] } +dash-sdk = { path = "../rs-sdk", default-features = false, features = ["dashpay-contract", "dpns-contract", "wallet", "mocks"] } [features] diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 006e9b01331..2bdf23cba69 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -161,3 +161,20 @@ pub fn is_instant_lock_proof_invalid(error: &dash_sdk::Error) -> bool { )) ) } + +/// Check whether a platform-wallet error represents a *Core-side* +/// InstantSend lock timeout (the asset-lock manager waited the full +/// timeout for an IS-lock proof and never observed one). +/// +/// Companion to [`is_instant_lock_proof_invalid`] (which detects +/// **Platform-side** rejection of an IS proof after one was obtained). +/// Both surfaces trigger the same fallback path in the registration / +/// top-up flow: upgrade the asset-lock to a ChainLock proof and retry. +/// +/// The IS-timeout shape comes from +/// [`AssetLockManager::wait_for_proof`](crate::wallet::asset_lock::manager::AssetLockManager), +/// which emits `PlatformWalletError::FinalityTimeout(Txid)` when the +/// 300-second IS deadline elapses. +pub fn is_instant_lock_timeout(error: &PlatformWalletError) -> bool { + matches!(error, PlatformWalletError::FinalityTimeout(_)) +} diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 98b9a609a43..a2a6cdd3848 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -60,9 +60,8 @@ pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, derive_contact_payment_addresses, derive_contact_xpub, BlockTime, ContactRequest, ContactXpubData, DashPayProfile, DpnsNameInfo, EstablishedContact, IdentityFunding, - IdentityFundingMethod, IdentityLocation, IdentityManager, IdentityStatus, KeyStorage, - ManagedIdentity, PrivateKeyData, ProfileUpdate, RegistrationIndex, TopUpFundingMethod, - DEFAULT_CONTACT_GAP_LIMIT, + IdentityLocation, IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, + PrivateKeyData, ProfileUpdate, RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::platform_wallet::PlatformWalletInfo; pub use wallet::PlatformAddressTag; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index d4ca680af55..dd8b8a8e886 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -7,8 +7,10 @@ use crate::broadcaster::TransactionBroadcaster; use std::time::Duration; use dashcore::Address as DashAddress; -use dashcore::{OutPoint, PrivateKey, Transaction, TxOut}; +use dashcore::{OutPoint, Transaction, TxOut}; +use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::signer::Signer; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{ AssetLockFundingType, CreditOutputFunding, }; @@ -27,8 +29,11 @@ use super::tracked::{AssetLockStatus, TrackedAssetLock}; impl AssetLockManager { /// Build an asset lock transaction using the key-wallet builder. /// - /// Delegates UTXO selection, fee calculation, change handling, and signing - /// to `ManagedWalletInfo::build_asset_lock`. + /// Delegates UTXO selection, fee calculation, and signing to + /// `ManagedWalletInfo::build_asset_lock_with_signer`. The host + /// never sees a raw credit-output private key — the returned + /// `DerivationPath` is what the caller hands back to the same + /// `signer` when the credit output is later consumed on Platform. /// /// # Arguments /// @@ -37,13 +42,21 @@ impl AssetLockManager { /// * `funding_type` — Which account to derive the one-time key from /// (e.g., `IdentityRegistration`, `IdentityTopUp`). /// * `identity_index` — Identity index (used by `IdentityTopUp`, ignored by others). - pub async fn build_asset_lock_transaction( + /// * `signer` — External signer that produces both the funding-input + /// P2PKH signatures and the credit-output public key. For Swift, + /// this is typically a + /// [`MnemonicResolverCoreSigner`](crate::wallet::asset_lock::build) + /// from `platform-wallet-ffi` — built on top of the + /// Keychain-resolver vtable so private keys never cross the FFI + /// boundary. + pub async fn build_asset_lock_transaction( &self, amount_duffs: u64, account_index: u32, funding_type: AssetLockFundingType, identity_index: u32, - ) -> Result<(Transaction, PrivateKey), PlatformWalletError> { + signer: &S, + ) -> Result<(Transaction, DerivationPath), PlatformWalletError> { if amount_duffs == 0 { return Err(PlatformWalletError::AssetLockTransaction( "Amount must be greater than zero".to_string(), @@ -76,10 +89,16 @@ impl AssetLockManager { identity_index, }; - // 3. Delegate to the key-wallet builder. + // 3. Delegate to the key-wallet signer-driven builder. let result = info .core_wallet - .build_asset_lock(wallet, account_index, vec![funding], DEFAULT_FEE_PER_KB) + .build_asset_lock_with_signer( + wallet, + account_index, + vec![funding], + DEFAULT_FEE_PER_KB, + signer, + ) .await .map_err(|e| { PlatformWalletError::AssetLockTransaction(format!( @@ -88,36 +107,31 @@ impl AssetLockManager { )) })?; - // 4. Convert the raw key bytes to a PrivateKey. + // 4. Pull the (pubkey, path) for our single credit output. // - // `build_asset_lock` is the soft-wallet variant, so the - // keys are always the `Private` variant of - // `AssetLockCreditKeys`. The `Public` variant would only - // come from `build_asset_lock_with_signer` (external - // signer path) which we don't use here. + // `build_asset_lock_with_signer` always returns the `Public` + // variant. The `Private` arm would only come from the soft- + // wallet `build_asset_lock` path which we no longer call from + // platform-wallet — defensively bail if it appears. use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockCreditKeys; - let key_bytes = match result.keys { - AssetLockCreditKeys::Private(mut keys) => keys.drain(..).next().ok_or_else(|| { - PlatformWalletError::AssetLockTransaction( - "Builder returned no credit-output keys".to_string(), - ) - })?, - AssetLockCreditKeys::Public(_) => { + let path = match result.keys { + AssetLockCreditKeys::Public(mut keys) => { + let (_pubkey, path) = keys.drain(..).next().ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Builder returned no credit-output keys".to_string(), + ) + })?; + path + } + AssetLockCreditKeys::Private(_) => { return Err(PlatformWalletError::AssetLockTransaction( - "Builder returned Public keys (signer path); expected Private from soft wallet" + "Builder returned Private keys; signer-driven path expected Public" .to_string(), )); } }; - let one_time_private_key = PrivateKey::from_byte_array(&key_bytes, self.sdk.network) - .map_err(|e| { - PlatformWalletError::AssetLockTransaction(format!( - "Invalid private key from builder: {}", - e - )) - })?; - Ok((result.transaction, one_time_private_key)) + Ok((result.transaction, path)) } /// Peek at the next unused address from a funding account without @@ -260,12 +274,15 @@ impl AssetLockManager { /// /// ## Flow /// - /// 1. Build the asset lock transaction via the key-wallet builder. + /// 1. Build the asset lock transaction via the key-wallet + /// signer-driven builder. /// 2. Track the lifecycle as `Built` (in-memory). /// 3. Broadcast the transaction. /// 4. Wait for an InstantLock or ChainLock proof via the event channel. /// 5. Track the lifecycle as `InstantSendLocked` or `ChainLocked`. - /// 6. Return `(proof, private_key, txid)`. + /// 6. Return `(proof, credit_output_derivation_path, txid)` — the + /// caller hands the path back to the same `signer` when + /// consuming the credit on Platform. /// /// ## Persistence /// @@ -284,16 +301,26 @@ impl AssetLockManager { /// * `funding_type` — Which account to derive the one-time key from. /// * `identity_index` — HD identity index (for `IdentityTopUp`, this is /// the registration index identifying which identity is being topped up). - pub async fn create_funded_asset_lock_proof( + /// * `signer` — External ECDSA signer (Swift Keychain-backed in + /// production via `MnemonicResolverCoreSigner`). + pub async fn create_funded_asset_lock_proof( &self, amount_duffs: u64, account_index: u32, funding_type: AssetLockFundingType, identity_index: u32, - ) -> Result<(dpp::prelude::AssetLockProof, PrivateKey, OutPoint), PlatformWalletError> { + signer: &S, + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> + { // 1. Build the asset lock transaction. - let (tx, key) = self - .build_asset_lock_transaction(amount_duffs, account_index, funding_type, identity_index) + let (tx, path) = self + .build_asset_lock_transaction( + amount_duffs, + account_index, + funding_type, + identity_index, + signer, + ) .await?; let txid = tx.txid(); @@ -352,6 +379,6 @@ impl AssetLockManager { .await?; self.queue_asset_lock_changeset(cs_final); - Ok((proof, key, out_point)) + Ok((proof, path, out_point)) } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 3afc0053612..a39e1e52fcd 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -106,6 +106,21 @@ impl AssetLockManager { // --------------------------------------------------------------------------- impl AssetLockManager { + /// Wallet id this manager operates on. Exposed so FFI callers that + /// build a `MnemonicResolverCoreSigner` (or similar) on demand can + /// thread the wallet id through to the resolver callback without + /// reaching into private fields. + pub fn wallet_id(&self) -> WalletId { + self.wallet_id + } + + /// Network the SDK was constructed with. Same rationale as + /// [`Self::wallet_id`] — needed by FFI callers that build a + /// `key_wallet::signer::Signer` per call. + pub fn network(&self) -> dashcore::Network { + self.sdk.network + } + /// List all tracked asset locks (blocking version for UI / synchronous contexts). /// /// Uses `tokio::sync::RwLock::blocking_read` — must NOT be called from diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index b26a140f3fd..2b2a27ccc38 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -8,7 +8,8 @@ use crate::broadcaster::TransactionBroadcaster; use std::time::Duration; use dashcore::Address as DashAddress; -use dashcore::{OutPoint, PrivateKey}; +use dashcore::OutPoint; +use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -198,15 +199,18 @@ impl AssetLockManager { /// (upgrading a stale IS-lock to a ChainLock proof if necessary). /// /// After obtaining the proof, advances the tracked lock status and - /// re-derives the one-time private key from the wallet. + /// re-derives the one-time credit-output derivation path from the + /// wallet's funding-account address pool. /// - /// Returns `(proof, private_key)` ready for use in identity registration - /// or top-up. + /// Returns `(proof, derivation_path)` ready for use in identity + /// registration or top-up via the `_with_signer` SDK methods. The + /// caller passes `derivation_path` to the same signer used for the + /// build phase when the credit output is later consumed on Platform. pub async fn resume_asset_lock( &self, out_point: &OutPoint, timeout: Duration, - ) -> Result<(dpp::prelude::AssetLockProof, PrivateKey), PlatformWalletError> { + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath), PlatformWalletError> { // 1. Look up the tracked lock — snapshot the fields we need. let (tx, status, existing_proof, account_index) = { let wm = self.wallet_manager.read().await; @@ -269,8 +273,8 @@ impl AssetLockManager { .await?; self.queue_asset_lock_changeset(cs); - // 4. Re-derive the one-time private key. - let private_key = { + // 4. Re-derive the one-time credit-output derivation path. + let path = { let wm = self.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -281,22 +285,31 @@ impl AssetLockManager { out_point )) })?; - self.rederive_private_key(lock).await? + self.rederive_credit_output_path(lock).await? }; - Ok((proof, private_key)) + Ok((proof, path)) } - /// Re-derive the one-time private key for a tracked asset lock. + /// Re-derive the one-time credit-output **derivation path** for a + /// tracked asset lock. /// /// The credit output address was generated from a funding account - /// (identity registration, top-up, etc.). This method finds that address - /// in the funding account's address pool, retrieves its derivation path, - /// and derives the private key from the wallet's root key. - async fn rederive_private_key( + /// (identity registration, top-up, etc.). This method finds that + /// address in the funding account's address pool and retrieves + /// its derivation path — the path is what the caller hands to a + /// `key_wallet::signer::Signer` when later consuming the credit + /// output on Platform. + /// + /// Previously this method derived the actual private key from the + /// wallet's root xpriv; that path is no longer reachable for + /// `ExternalSignable` wallets (the root key isn't in-process) and + /// the signer-based architecture doesn't need it — the signer + /// owns derivation end-to-end. + async fn rederive_credit_output_path( &self, lock: &TrackedAssetLock, - ) -> Result { + ) -> Result { use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -374,17 +387,6 @@ impl AssetLockManager { )) })?; - // 4. Derive the private key from the wallet's root key. - let wallet = wm - .get_wallet(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let secret_key = wallet.derive_private_key(&derivation_path).map_err(|e| { - PlatformWalletError::AssetLockTransaction(format!( - "Failed to derive private key for asset lock: {}", - e - )) - })?; - - Ok(PrivateKey::new(secret_key, self.sdk.network)) + Ok(derivation_path) } } diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index c4ab34f3ab5..4609d1fb6d2 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,6 +1,7 @@ use dashcore::{Address as DashAddress, Transaction}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::signer::Signer; use crate::broadcaster::TransactionBroadcaster; use crate::{CoreWallet, PlatformWalletError}; @@ -25,9 +26,19 @@ impl CoreWallet { /// Build, sign, and broadcast a payment to the given addresses. /// - /// Uses key-wallet's [`TransactionBuilder`] for UTXO selection, fee - /// estimation, and signing. Change is sent to the next internal address - /// of the specified account. + /// Uses key-wallet's + /// [`TransactionBuilder`](key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder) + /// for UTXO selection, fee estimation, and signing. Change is sent to + /// the next internal address of the specified account. + /// + /// Signing is delegated to the caller-supplied + /// [`Signer`](key_wallet::signer::Signer) via the + /// `impl TransactionSigner for S` blanket in + /// `key-wallet`'s `transaction_builder.rs`. For Swift wallets this + /// is typically a + /// [`MnemonicResolverCoreSigner`](crate::wallet::asset_lock::build) + /// from `platform-wallet-ffi`, backed by the Keychain-resolver + /// vtable so private keys never cross the FFI boundary. /// /// **Note (smell):** the body of this method is a near-duplicate of /// `ManagedWalletInfo::build_and_sign_transaction` in `key-wallet` @@ -35,11 +46,12 @@ impl CoreWallet { /// It's reimplemented here because the upstream helper is BIP-44-only, /// parametrizing upstream on `AccountTypePreference` so it picks /// `standard_bip{32,44}_accounts` would be a trivial change - pub async fn send_to_addresses( + pub async fn send_to_addresses( &self, account_type: StandardAccountType, account_index: u32, outputs: Vec<(DashAddress, u64)>, + signer: &S, ) -> Result { use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; @@ -108,6 +120,11 @@ impl CoreWallet { ), }; + // The blanket `impl TransactionSigner for S` in + // `key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs:482` + // makes the signer drop-in for the previously `Wallet`-backed + // path; the funds-derived `address_derivation_path` lookup is + // unchanged. let mut builder = TransactionBuilder::new() .set_current_height(current_height) .set_selection_strategy(SelectionStrategy::LargestFirst) @@ -117,7 +134,7 @@ impl CoreWallet { } let (tx, _fee) = builder - .build_signed(wallet, |addr| { + .build_signed(signer, |addr| { managed_account.address_derivation_path(&addr) }) .await diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 5a29db29002..8e83fd6b947 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -55,6 +55,14 @@ impl CoreWallet { &self.balance } + /// Wallet id this `CoreWallet` operates on. Exposed so FFI + /// callers that need to construct a per-call `Signer` (e.g. + /// `MnemonicResolverCoreSigner`) can thread the same wallet id + /// the resolver callback will receive. + pub fn wallet_id(&self) -> WalletId { + self.wallet_id + } + /// Get the next unused BIP-44 external (receive) address for a specific account. pub async fn next_receive_address_for_account( &self, diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 25fcf426a8b..c2c567185da 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -34,6 +34,6 @@ pub use state::{BlockTime, IdentityLocation, IdentityManager, ManagedIdentity, R pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ ContactRequest, DashPayProfile, DashpayAddressMatch, DpnsNameInfo, EstablishedContact, - IdentityFunding, IdentityFundingMethod, IdentityStatus, KeyStorage, PaymentDirection, - PaymentEntry, PaymentStatus, PrivateKeyData, ProfileUpdate, TopUpFundingMethod, + IdentityFunding, IdentityStatus, KeyStorage, PaymentDirection, PaymentEntry, PaymentStatus, + PrivateKeyData, ProfileUpdate, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index 8d5943235c2..67c36c2ff33 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -1,8 +1,52 @@ -//! Identity registration flows. +//! Identity registration and top-up flows. +//! +//! ## Two-layer factoring +//! +//! Both registration and top-up are factored as a thin **L1 primitive** +//! wrapping the SDK's `_with_signer` calls, and a **L2 orchestration +//! method** that does pre-flight, funding resolution, IS→CL fallback, +//! and IdentityManager bookkeeping. +//! +//! | Layer | Registration | Top-up | +//! |-------|------------------------------------|-----------------------------------| +//! | L1 | [`register_identity_with_signer`] | [`top_up_identity_with_signer`] | +//! | L2 | [`register_identity_with_funding`] | [`top_up_identity_with_funding`] | +//! +//! [`register_identity_with_signer`]: IdentityWallet::register_identity_with_signer +//! [`top_up_identity_with_signer`]: IdentityWallet::top_up_identity_with_signer +//! [`register_identity_with_funding`]: IdentityWallet::register_identity_with_funding +//! [`top_up_identity_with_funding`]: IdentityWallet::top_up_identity_with_funding +//! +//! The L2 methods are the canonical entry points. The L1 primitives are +//! `pub` so callers that manage funding outside this crate (evo-tool's +//! tasks, integration tests) can submit a pre-built proof directly. +//! +//! ## IS→CL fallback (the "stuck asset-lock" bug it fixes) +//! +//! L2 covers two distinct surfaces where an IS-lock can fail: +//! +//! 1. **Core-side timeout** — `create_funded_asset_lock_proof` returns +//! `PlatformWalletError::FinalityTimeout` because the IS-lock +//! didn't propagate within 300s. Detected via +//! [`crate::error::is_instant_lock_timeout`]. L2 calls +//! `upgrade_to_chain_lock_proof` to wait for a ChainLock, then +//! re-enters submission with the CL proof. +//! +//! 2. **Platform-side rejection** — `put_to_platform_and_wait_for_response_with_signer` +//! returns `InvalidInstantAssetLockProofSignatureError` (the +//! consensus error Drive emits when the IS-lock signing quorum has +//! rotated out). Detected via +//! [`crate::error::is_instant_lock_proof_invalid`]. Same recovery: +//! upgrade to ChainLock and retry. +//! +//! Both paths share the same outpoint-keyed cleanup (`remove_asset_lock`) +//! once Platform finally accepts the registration / top-up. use std::collections::BTreeMap; use std::time::Duration; +use dashcore::OutPoint; +use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; @@ -12,70 +56,320 @@ use dpp::identity::Purpose; use dpp::identity::SecurityLevel; use dpp::prelude::AssetLockProof; use dpp::prelude::Identifier; +use key_wallet::bip32::DerivationPath; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; -use crate::error::PlatformWalletError; -// PrivateKeyData no longer needed at the registration call sites — -// `add_key` takes a flat `Option<(wallet_id, identity_index, key_index)>` -// breadcrumb directly. - +use crate::error::{is_instant_lock_proof_invalid, is_instant_lock_timeout, PlatformWalletError}; use crate::wallet::identity::types::funding::IdentityFunding; use super::*; -use crate::wallet::identity::types::funding::IdentityFundingMethod; // --------------------------------------------------------------------------- -// Identity registration +// Timeout policy +// --------------------------------------------------------------------------- + +/// Time we will wait for a ChainLock to materialise after an IS-lock +/// fallback is triggered. 180s mirrors the existing fallback shape and +/// is roughly the worst-case ChainLock latency we've observed in +/// testnet operation. Promoted to a constant so the registration and +/// top-up flows can't drift apart on this number. +const CL_FALLBACK_TIMEOUT: Duration = Duration::from_secs(180); + +// --------------------------------------------------------------------------- +// Funding resolution (shared between register and top-up) // --------------------------------------------------------------------------- +/// Outcome of resolving an [`IdentityFunding`] to a concrete asset-lock +/// proof + derivation path. +/// +/// `tracked_out_point` is `Some` whenever this wallet's +/// `AssetLockManager` owns the lifecycle of the underlying asset lock +/// — i.e. for `FromWalletBalance` (we just built and tracked it) and +/// `FromExistingAssetLock` (caller is resuming a tracked entry). It's +/// `None` for `UseAssetLock` where the caller has externally-managed +/// proofs and we shouldn't touch the tracked-asset-lock map. The +/// outpoint also drives both IS→CL fallback (look up the lock by +/// outpoint) and cleanup (remove the lock on Platform success). +struct ResolvedFunding { + proof: AssetLockProof, + path: DerivationPath, + tracked_out_point: Option, +} + +/// Outcome of [`IdentityWallet::resolve_funding_with_is_timeout_fallback`]: +/// either a fully-resolved funding triple, or an IS-timeout that the +/// caller can convert to a ChainLock retry using the recovered +/// outpoint. +enum FundingResolution { + Resolved(ResolvedFunding), + /// IS-lock didn't propagate within the asset-lock manager's wait + /// window. The outpoint of the tracked-but-unproven lock is + /// surfaced so the caller can drive an `upgrade_to_chain_lock_proof` + /// retry without re-walking the tracked-asset-lock map. + IsTimeout { + out_point: OutPoint, + }, +} + impl IdentityWallet { - /// Register a new asset-lock-funded identity on Platform using an - /// externally-supplied signer + caller-derived authentication keys. + /// Resolve an [`IdentityFunding`] to a concrete proof + path + + /// (optional) tracked outpoint, capturing the IS-lock timeout case + /// as a structured outcome so the caller can drive a CL retry. /// - /// The caller must provide: + /// `funding_type` selects the BIP44 account the + /// `FromWalletBalance` variant pulls UTXOs from + /// (`IdentityRegistration` for register, `IdentityTopUp` for top + /// up). The other variants ignore it — they don't build new + /// asset locks. /// - /// - `funding`: an `IdentityFundingMethod`. - /// - `identity_index`: BIP-9 identity index. - /// - `keys_map`: the auth pubkeys the new identity will be created - /// with. Caller must derive these from the wallet seed (or from - /// iOS Keychain via `dash_sdk_derive_identity_keys_from_mnemonic`) - /// and persist the matching private keys to whatever store the - /// `signer` reads from. The first key (id=0) MUST be a MASTER / - /// AUTHENTICATION key — DPP's IdentityCreate state transition - /// itself must be signed by a MASTER-level identity key, and we - /// pin that role on id=0 by convention so callers don't need - /// protocol knowledge to assemble the map. The asset-lock-spend - /// signature on the same transition is a separate signature - /// keyed off `asset_lock_private_key`, supplied via `funding`. - /// - `signer`: external signer for the IdentityCreate transition's - /// per-key signatures. + /// # IS-lock timeout handling /// - /// On success the new identity is added to the local manager and - /// each key is recorded with its derivation breadcrumb for the - /// persister callback. IS->CL fallback is retained. - pub async fn register_identity_with_funding_external_signer( + /// For the two variants that internally invoke `wait_for_proof` + /// (`FromWalletBalance` and `FromExistingAssetLock`), an IS-lock + /// that never propagates within the 300s window surfaces as + /// `PlatformWalletError::FinalityTimeout`. We catch that here and + /// return `FundingResolution::IsTimeout` with the outpoint of the + /// tracked-but-unproven asset lock — for `FromExistingAssetLock` + /// we already know it (the variant carries it directly), for + /// `FromWalletBalance` we recover it via + /// [`find_tracked_unproven_lock`](Self::find_tracked_unproven_lock). + async fn resolve_funding_with_is_timeout_fallback( &self, - funding: IdentityFundingMethod, + funding: IdentityFunding, + funding_type: AssetLockFundingType, identity_index: u32, + asset_lock_signer: &AS, + ) -> Result + where + AS: ::key_wallet::signer::Signer + Send + Sync, + { + match funding { + IdentityFunding::FromWalletBalance { amount_duffs } => { + match self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + 0, + funding_type, + identity_index, + asset_lock_signer, + ) + .await + { + Ok((proof, path, out_point)) => { + Ok(FundingResolution::Resolved(ResolvedFunding { + proof, + path, + tracked_out_point: Some(out_point), + })) + } + Err(e) if is_instant_lock_timeout(&e) => { + // We don't have the outpoint directly because + // create_funded_asset_lock_proof consumes the + // result. The asset-lock manager tracked the + // lock before broadcast — find it back via + // (funding_type, identity_index). + let out_point = self + .find_tracked_unproven_lock(funding_type, identity_index) + .await?; + Ok(FundingResolution::IsTimeout { out_point }) + } + Err(e) => Err(e), + } + } + IdentityFunding::FromExistingAssetLock { out_point } => { + match self + .asset_locks + .resume_asset_lock(&out_point, Duration::from_secs(300)) + .await + { + Ok((proof, path)) => Ok(FundingResolution::Resolved(ResolvedFunding { + proof, + path, + tracked_out_point: Some(out_point), + })), + Err(e) if is_instant_lock_timeout(&e) => { + // We already know the outpoint from the + // variant — no need to walk the tracked map. + Ok(FundingResolution::IsTimeout { out_point }) + } + Err(e) => Err(e), + } + } + IdentityFunding::UseAssetLock { + proof, + derivation_path, + } => Ok(FundingResolution::Resolved(ResolvedFunding { + proof, + path: derivation_path, + // Caller owns the lock lifecycle — don't touch the + // tracked-asset-lock map. + tracked_out_point: None, + })), + } + } +} + +// --------------------------------------------------------------------------- +// L1 primitives — pure submit, no funding/bookkeeping/fallback +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// **L1 primitive**: submit an identity-create state transition using a + /// caller-supplied asset-lock proof + derivation path + signer pair. + /// + /// Builds a placeholder `Identity` from `keys_map` internally + /// (caller doesn't need to materialise the DPP type). The first key + /// (id=0) MUST be a MASTER + AUTHENTICATION key — this is enforced + /// here defensively so a malformed map fails fast. + /// + /// No funding resolution, no bookkeeping, no fallback. The L2 + /// orchestrator [`register_identity_with_funding`](Self::register_identity_with_funding) + /// owns those concerns and calls this primitive twice (once with + /// the IS proof, once with the CL proof on IS→CL fallback) so the + /// retry is byte-identical to the first attempt. + /// + /// # Send + Sync bounds + /// + /// Both `S` and `AS` carry `Send + Sync` bounds even though this + /// method's body doesn't `tokio::spawn`. The bounds match the L2 + /// orchestrator's so callers don't have to think about which layer + /// imposes which constraint. This unblocks future `tokio::spawn`- + /// driven refactors at the L2 site without a backwards-incompatible + /// trait-bound change here. + pub async fn register_identity_with_signer( + &self, keys_map: BTreeMap, - signer: &S, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &DerivationPath, + asset_lock_signer: &AS, + identity_signer: &S, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + AS: ::key_wallet::signer::Signer + Send + Sync, + { + let identity = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + identity + .put_to_platform_and_wait_for_response_with_signer( + &self.sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + identity_signer, + settings, + ) + .await + } + + /// **L1 primitive**: submit an identity-top-up state transition + /// using a caller-supplied identity + asset-lock proof + derivation + /// path + signer. + /// + /// No funding resolution, no bookkeeping, no fallback. The L2 + /// orchestrator [`top_up_identity_with_funding`](Self::top_up_identity_with_funding) + /// owns those concerns. + /// + /// Returns the post-transition credit balance. + /// + /// `Send + Sync` rationale: same as + /// [`register_identity_with_signer`](Self::register_identity_with_signer). + pub async fn top_up_identity_with_signer( + &self, + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &DerivationPath, + asset_lock_signer: &AS, + settings: Option, + ) -> Result + where + AS: ::key_wallet::signer::Signer + Send + Sync, + { + identity + .top_up_identity_with_signer( + &self.sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + settings.and_then(|s| s.user_fee_increase), + settings, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// L2 orchestrator — register +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// **L2 orchestrator**: register a new asset-lock-funded identity + /// on Platform. + /// + /// Single entry point for every register-with-asset-lock case: + /// + /// 1. Pre-flight — validate `keys_map[0]` is a MASTER + + /// AUTHENTICATION key (the IdentityCreate transition itself + /// must be signed by a MASTER-level identity key, and we pin + /// that role on id=0 by convention). + /// 2. Resolve the [`IdentityFunding`] to an asset-lock proof + + /// derivation path. + /// 3. Submit via the [L1 primitive](Self::register_identity_with_signer), + /// with IS→CL fallback on **both** Core-side timeout + /// (`FinalityTimeout`) and Platform-side rejection + /// (`InvalidInstantAssetLockProofSignatureError`). + /// 4. On success, add the confirmed identity to the local + /// `IdentityManager` and record each key's derivation breadcrumb. + /// 5. Remove the tracked asset lock (if any) — the credit output + /// has been consumed, so the entry is no longer needed. + /// + /// # The IS→CL fallback path + /// + /// The Core-side timeout fallback is the architectural fix this + /// iter introduces. Before, `create_funded_asset_lock_proof`'s + /// 300s IS-lock timeout was terminal: a chain-locked but + /// IS-unlocked asset-lock would leave the funded DASH stranded. + /// The fix uses the **same** asset-lock signer for the CL retry — + /// no priv-key materialisation Rust-side — so the "no private keys + /// outside Swift, even briefly between operations" architectural + /// invariant is preserved. + /// + /// # Idempotency note + /// + /// The IS→CL retry is bounded (180s waiting for ChainLock). If the + /// CL retry itself fails, the asset-lock stays tracked (cleanup + /// only runs on Platform success) so subsequent registration + /// attempts can resume via `FromExistingAssetLock`. + pub async fn register_identity_with_funding( + &self, + funding: IdentityFunding, + identity_index: u32, + keys_map: BTreeMap, + identity_signer: &S, + asset_lock_signer: &AS, settings: Option, ) -> Result where S: Signer + Send + Sync, + AS: ::key_wallet::signer::Signer + Send + Sync, { + // Step 1: pre-flight on the caller-supplied keys map. if keys_map.is_empty() { return Err(PlatformWalletError::InvalidIdentityData( "keys_map must contain at least one identity public key".to_string(), )); } - // Defensive: pin id=0 to MASTER+AUTHENTICATION at the FFI - // boundary so a malformed map fails fast here instead of - // surfacing as an opaque protocol-side rejection from - // `put_to_platform_and_wait_for_response`. { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; match keys_map.get(&0) { @@ -91,84 +385,124 @@ impl IdentityWallet { } None => { return Err(PlatformWalletError::InvalidIdentityData( - "keys_map must include key id=0 with MASTER security level".to_string(), + "keys_map must include key id=0 with MASTER security level" + .to_string(), )); } } } - // Step 1: obtain asset lock proof + private key. - let (asset_lock_proof, asset_lock_private_key) = match funding { - IdentityFundingMethod::UseAssetLock { proof, private_key } => (proof, private_key), - IdentityFundingMethod::FundWithWallet { amount_duffs } => { - use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; - let (proof, key, _out_point) = self + // Step 2: resolve funding to a proof + derivation path. The + // resolver catches IS-lock timeouts and surfaces them as a + // structured outcome carrying the tracked outpoint, so the + // CL retry uses the SAME credit output (no new asset lock). + let ResolvedFunding { + proof, + path, + tracked_out_point, + } = match self + .resolve_funding_with_is_timeout_fallback( + funding, + AssetLockFundingType::IdentityRegistration, + identity_index, + asset_lock_signer, + ) + .await? + { + FundingResolution::Resolved(rf) => rf, + FundingResolution::IsTimeout { out_point } => { + tracing::warn!( + "IS-lock did not propagate within 300s for funded identity registration \ + (tx {}), falling back to ChainLock proof", + out_point.txid + ); + let chain_proof = self .asset_locks - .create_funded_asset_lock_proof( - amount_duffs, - 0, - AssetLockFundingType::IdentityRegistration, - identity_index, - ) + .upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) + .await?; + // Recover the credit-output derivation path. The + // asset lock is now CL-attached (status advanced by + // `upgrade_to_chain_lock_proof`'s caller path), so + // `resume_asset_lock` short-circuits to the existing- + // proof branch and just re-derives the path. This is + // cheap (no SPV wait) and avoids duplicating the + // path-derivation logic here. + let (_, path) = self + .asset_locks + .resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT) .await?; - (proof, key) + ResolvedFunding { + proof: chain_proof, + path, + tracked_out_point: Some(out_point), + } } }; - // Step 2: build the placeholder identity from caller-supplied keys. - let identity = Identity::V0(IdentityV0 { + // Build the placeholder identity ONCE so both the primary + // attempt and the IS→CL retry submit the same key set. This + // bypasses the L1 primitive — which takes `keys_map` by value + // — so the retry doesn't need a deep clone of the BTreeMap. + // The L1 helper exists for *single-shot* callers; L2 owns the + // fallback shape and inlines the SDK call to avoid the by-value + // ergonomics issue. + let placeholder = Identity::V0(IdentityV0 { id: Identifier::default(), public_keys: keys_map, balance: 0, revision: 0, }); - // Step 3: submit, with IS->CL fallback on InstantSend rejection. - let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); - - let identity = match identity - .put_to_platform_and_wait_for_response( + // Step 3: submit, with Platform-side IS→CL fallback on + // InstantSend rejection. The retry path uses the SAME + // credit-output outpoint — no new asset lock built, no new + // funding-tx broadcast. + let proof_out_point = Self::out_point_from_proof(&proof); + let identity = match placeholder + .put_to_platform_and_wait_for_response_with_signer( &self.sdk, - asset_lock_proof, - &asset_lock_private_key, - signer, + proof, + &path, + asset_lock_signer, + identity_signer, settings, ) .await { Ok(identity) => identity, - Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { - if let Some(out_point) = proof_out_point { - tracing::warn!( - "IS-lock proof rejected for identity registration (tx {}, external signer), \ - retrying with ChainLock proof", - out_point.txid - ); - let chain_proof = self - .asset_locks - .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) - .await?; - identity - .put_to_platform_and_wait_for_response( - &self.sdk, - chain_proof, - &asset_lock_private_key, - signer, - settings, - ) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to register identity on Platform (ChainLock retry): {}", - e - )) - })? - } else { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Failed to register identity on Platform: {}", + Err(e) if is_instant_lock_proof_invalid(&e) => { + let out_point = proof_out_point.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "IS-lock rejected by Platform but proof carried no \ + outpoint we could upgrade: {}", e - ))); - } + )) + })?; + tracing::warn!( + "IS-lock proof rejected by Platform for identity registration (tx {}), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) + .await?; + placeholder + .put_to_platform_and_wait_for_response_with_signer( + &self.sdk, + chain_proof, + &path, + asset_lock_signer, + identity_signer, + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform (ChainLock retry): {}", + e + )) + })? } Err(e) => { return Err(PlatformWalletError::InvalidIdentityData(format!( @@ -178,16 +512,14 @@ impl IdentityWallet { } }; - // Step 4: add to local manager + record key derivation - // breadcrumbs (mirrors the legacy variant exactly so the - // persister callback fires the same way regardless of which - // path produced the identity). + // Step 4: bookkeeping — add to local IdentityManager + record + // key derivation breadcrumbs. { use dpp::identity::accessors::IdentityGettersV0; let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( + PlatformWalletError::WalletNotFound( "Wallet info not found in wallet manager".to_string(), ) })?; @@ -219,273 +551,271 @@ impl IdentityWallet { } } - Ok(identity) - } + // Step 5: clean up the tracked asset lock — Platform has + // accepted the registration and the credit output is now + // consumed. Only fires for the variants where we own the + // lifecycle (`FromWalletBalance` / `FromExistingAssetLock`); + // `UseAssetLock` is `None` and skipped. + if let Some(out_point) = tracked_out_point { + self.asset_locks.remove_asset_lock(&out_point).await; + } - /// Register a new identity using an externally-provided identity, asset - /// lock proof, and signer. - /// - /// Unlike - /// [`register_identity_with_funding_external_signer`](Self::register_identity_with_funding_external_signer), - /// this method does **not** derive keys or manage the internal - /// `IdentityManager`. The caller supplies a fully-constructed `Identity` - /// object, the asset lock proof + private key, and a `Signer` - /// implementation directly. - /// - /// This is useful when the caller manages identities outside of the - /// platform-wallet `IdentityManager` (e.g. evo-tool's - /// `QualifiedIdentity`). - /// - /// Returns the confirmed `Identity` from Platform. - pub async fn register_identity_with_signer>( - &self, - identity: &Identity, - asset_lock_proof: AssetLockProof, - asset_lock_private_key: &dashcore::PrivateKey, - signer: &S, - settings: Option, - ) -> Result { - identity - .put_to_platform_and_wait_for_response( - &self.sdk, - asset_lock_proof, - asset_lock_private_key, - signer, - settings, - ) - .await + Ok(identity) } +} - /// Top up an identity's credit balance using an externally-provided - /// identity and asset lock proof. - /// - /// Unlike [`top_up_identity_with_funding`](Self::top_up_identity_with_funding), - /// this method does **not** look up the identity in the internal - /// `IdentityManager`. The caller supplies the `Identity` object and the - /// asset lock proof + private key directly. - /// - /// This is useful when the caller manages identities outside of the - /// platform-wallet `IdentityManager` (e.g. evo-tool's - /// `QualifiedIdentity`). - /// - /// Returns the new credit balance. - pub async fn top_up_identity_with_signer( - &self, - identity: &Identity, - asset_lock_proof: AssetLockProof, - asset_lock_private_key: &dashcore::PrivateKey, - settings: Option, - ) -> Result { - identity - .top_up_identity( - &self.sdk, - asset_lock_proof, - asset_lock_private_key, - settings.and_then(|s| s.user_fee_increase), - settings, - ) - .await - } +// --------------------------------------------------------------------------- +// L2 orchestrator — top-up +// --------------------------------------------------------------------------- - /// Register a new identity using an [`IdentityFunding`] variant and an - /// externally-provided identity + signer. - /// - /// This method unifies funding resolution and Platform submission in a - /// single call: - /// - /// * **`FromWalletBalance`** — builds an asset lock from wallet UTXOs via - /// [`AssetLockManager::create_funded_asset_lock_proof`], then submits the - /// identity registration to Platform. - /// * **`FromExistingAssetLock`** — resumes a tracked asset lock by outpoint, - /// re-deriving the proof and private key from whatever stage the lock - /// is at. +impl IdentityWallet { + /// **L2 orchestrator**: top up an existing identity's credit balance. /// - /// Unlike - /// [`register_identity_with_funding_external_signer`](Self::register_identity_with_funding_external_signer), - /// this method does **not** derive keys or manage the internal - /// `IdentityManager`. The caller supplies a fully-constructed `Identity` - /// and a `Signer` implementation, making it suitable for callers that - /// manage identities externally (e.g. evo-tool's `QualifiedIdentity`). + /// Mirror of [`register_identity_with_funding`](Self::register_identity_with_funding) + /// for top-ups: /// - /// Returns the confirmed `Identity` from Platform. - pub async fn funded_register_identity>( + /// 1. Look up the identity by `identity_id` in the local + /// `IdentityManager`. Return `IdentityNotFound` if missing. + /// 2. Resolve the [`IdentityFunding`] to an asset-lock proof. + /// 3. Submit via the [L1 primitive](Self::top_up_identity_with_signer), + /// with IS→CL fallback on Core-side timeout and Platform-side + /// rejection (same as register). + /// 4. Persist the new credit balance + remove the tracked asset + /// lock. + pub async fn top_up_identity_with_funding( &self, - identity: &Identity, + identity_id: &Identifier, funding: IdentityFunding, - identity_index: u32, - signer: &S, + asset_lock_signer: &AS, settings: Option, - ) -> Result { - use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + ) -> Result + where + AS: ::key_wallet::signer::Signer + Send + Sync, + { + // Step 1: retrieve the identity + its HD index. + let (identity, identity_index) = { + 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(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + (identity, index) + }; - let (asset_lock_proof, asset_lock_private_key, tracked_out_point) = match funding { - IdentityFunding::FromWalletBalance { amount_duffs } => { - let (proof, key, out_point) = self + // Step 2: resolve funding. Same IS→CL fallback shape as + // `register_identity_with_funding` — see that method for the + // architectural rationale. + let ResolvedFunding { + proof, + path, + tracked_out_point, + } = match self + .resolve_funding_with_is_timeout_fallback( + funding, + AssetLockFundingType::IdentityTopUp, + identity_index, + asset_lock_signer, + ) + .await? + { + FundingResolution::Resolved(rf) => rf, + FundingResolution::IsTimeout { out_point } => { + tracing::warn!( + "IS-lock did not propagate within 300s for funded identity top-up \ + (tx {}), falling back to ChainLock proof", + out_point.txid + ); + let chain_proof = self .asset_locks - .create_funded_asset_lock_proof( - amount_duffs, - 0, - AssetLockFundingType::IdentityRegistration, - identity_index, - ) + .upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) .await?; - (proof, key, Some(out_point)) - } - IdentityFunding::FromExistingAssetLock { out_point } => { - let (proof, key) = self + let (_, path) = self .asset_locks - .resume_asset_lock(&out_point, Duration::from_secs(300)) + .resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT) .await?; - (proof, key, Some(out_point)) + ResolvedFunding { + proof: chain_proof, + path, + tracked_out_point: Some(out_point), + } } }; - // Extract the outpoint before consuming the proof, in case we need to - // build a ChainLock proof for recovery. - let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); - - let result = match self - .register_identity_with_signer( - identity, - asset_lock_proof, - &asset_lock_private_key, - signer, + // Step 3: submit. Platform-side IS→CL fallback on rejection. + let proof_out_point = Self::out_point_from_proof(&proof); + let new_balance = match self + .top_up_identity_with_signer( + &identity, + proof, + &path, + asset_lock_signer, settings, ) .await { - Ok(identity) => identity, - Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { - if let Some(out_point) = proof_out_point { - tracing::warn!( - "IS-lock proof rejected for funded identity registration (tx {}), \ - retrying with ChainLock proof", - out_point.txid - ); - let chain_proof = self - .asset_locks - .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) - .await?; - self.register_identity_with_signer( - identity, - chain_proof, - &asset_lock_private_key, - signer, - settings, - ) - .await - .map_err(PlatformWalletError::Sdk)? - } else { - return Err(PlatformWalletError::Sdk(e)); - } + Ok(balance) => balance, + Err(e) if is_instant_lock_proof_invalid(&e) => { + let out_point = proof_out_point.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "IS-lock rejected by Platform but proof carried no \ + outpoint we could upgrade: {}", + e + )) + })?; + tracing::warn!( + "IS-lock proof rejected by Platform for identity top-up (tx {}), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT) + .await?; + self.top_up_identity_with_signer( + &identity, + chain_proof, + &path, + asset_lock_signer, + settings, + ) + .await + .map_err(PlatformWalletError::Sdk)? } Err(e) => return Err(PlatformWalletError::Sdk(e)), }; - // Clean up the tracked asset lock after successful consumption. + // Step 4: persist the new balance + clean up the tracked lock. + { + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + managed.identity.set_balance(new_balance); + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::error!( + identity = %identity_id, + error = %e, + "Failed to persist identity balance update after top_up" + ); + } + } + } if let Some(out_point) = tracked_out_point { self.asset_locks.remove_asset_lock(&out_point).await; } - Ok(result) + Ok(new_balance) } +} - /// Top up an identity using an [`IdentityFunding`] variant and an - /// externally-provided identity. - /// - /// This method unifies funding resolution and Platform submission in a - /// single call: - /// - /// * **`FromWalletBalance`** — builds an asset lock from wallet UTXOs via - /// [`AssetLockManager::create_funded_asset_lock_proof`], then submits the - /// top-up to Platform. - /// * **`FromExistingAssetLock`** — resumes a tracked asset lock by outpoint, - /// re-deriving the proof and private key from whatever stage the lock - /// is at. +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Look up the most-recently-tracked asset lock for + /// `(funding_type, identity_index)` that has no attached proof + /// (status `Built` or `Broadcast`). /// - /// Unlike [`top_up_identity_with_funding`](Self::top_up_identity_with_funding), - /// this method does **not** look up the identity in the internal - /// `IdentityManager`. The caller supplies the `Identity` object directly, - /// making it suitable for callers that manage identities externally - /// (e.g. evo-tool's `QualifiedIdentity`). + /// Used by the IS→CL Core-side timeout fallback path: when + /// `wait_for_proof` times out, the asset-lock manager has already + /// tracked the lock under its outpoint, but we lost the outpoint + /// along with the result. This helper recovers it from the + /// tracked-asset-lock map. /// - /// Returns the new credit balance. - pub async fn funded_top_up_identity( + /// Returns the outpoint of the matching lock, or an error if no + /// matching lock is found (which would indicate a wallet-state + /// mismatch — `wait_for_proof` shouldn't have returned a timeout + /// without first tracking the lock). + async fn find_tracked_unproven_lock( &self, - identity: &Identity, - funding: IdentityFunding, + funding_type: AssetLockFundingType, identity_index: u32, - settings: Option, - ) -> Result { - use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + ) -> Result { + use crate::wallet::asset_lock::tracked::AssetLockStatus; - let (asset_lock_proof, asset_lock_private_key, tracked_out_point) = match funding { - IdentityFunding::FromWalletBalance { amount_duffs } => { - let (proof, key, out_point) = self - .asset_locks - .create_funded_asset_lock_proof( - amount_duffs, - 0, - AssetLockFundingType::IdentityTopUp, - identity_index, + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.tracked_asset_locks + .iter() + .find(|(_, lock)| { + lock.funding_type == funding_type + && lock.identity_index == identity_index + && matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast ) - .await?; - (proof, key, Some(out_point)) - } - IdentityFunding::FromExistingAssetLock { out_point } => { - let (proof, key) = self - .asset_locks - .resume_asset_lock(&out_point, Duration::from_secs(300)) - .await?; - (proof, key, Some(out_point)) - } - }; + && lock.proof.is_none() + }) + .map(|(out_point, _)| *out_point) + .ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "IS-lock timeout fallback: no tracked unproven asset lock found \ + for funding_type={:?} identity_index={}", + funding_type, identity_index + )) + }) + } +} - // Extract the outpoint before consuming the proof, in case we need to - // build a ChainLock proof for recovery. - let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- - let new_balance = match self - .top_up_identity_with_signer( - identity, - asset_lock_proof, - &asset_lock_private_key, - settings, - ) - .await - { - Ok(balance) => balance, - Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { - if let Some(out_point) = proof_out_point { - tracing::warn!( - "IS-lock proof rejected for funded identity top-up (tx {}), \ - retrying with ChainLock proof", - out_point.txid - ); - let chain_proof = self - .asset_locks - .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) - .await?; - self.top_up_identity_with_signer( - identity, - chain_proof, - &asset_lock_private_key, - settings, - ) - .await - .map_err(PlatformWalletError::Sdk)? - } else { - return Err(PlatformWalletError::Sdk(e)); - } - } - Err(e) => return Err(PlatformWalletError::Sdk(e)), - }; +#[cfg(test)] +mod tests { + use super::*; + use crate::error::is_instant_lock_timeout; + use dashcore::Txid; - // Clean up the tracked asset lock after successful consumption. - if let Some(out_point) = tracked_out_point { - self.asset_locks.remove_asset_lock(&out_point).await; - } + /// Pins the IS-timeout discriminator: only `FinalityTimeout` + /// matches, so the L2 fallback arms route exactly the cases we + /// expect. Companion to `is_instant_lock_proof_invalid` (which + /// discriminates SDK errors at the Platform-rejection boundary). + #[test] + fn is_instant_lock_timeout_only_matches_finality_timeout() { + let timeout = PlatformWalletError::FinalityTimeout(Txid::from([0u8; 32])); + assert!( + is_instant_lock_timeout(&timeout), + "FinalityTimeout must route to IS→CL fallback" + ); - Ok(new_balance) + // Adjacent error shapes that share the asset-lock domain but + // are NOT timeouts — must NOT trigger the fallback. + let expired = PlatformWalletError::AssetLockExpired("CL not yet available".to_string()); + assert!( + !is_instant_lock_timeout(&expired), + "AssetLockExpired must NOT trigger IS→CL fallback \ + (the lock is already past the CL grace window)" + ); + + let not_cl = PlatformWalletError::AssetLockNotChainLocked("missing".to_string()); + assert!( + !is_instant_lock_timeout(¬_cl), + "AssetLockNotChainLocked must NOT trigger IS→CL fallback" + ); + + let wait_err = PlatformWalletError::AssetLockProofWait("not tracked".to_string()); + assert!( + !is_instant_lock_timeout(&wait_err), + "AssetLockProofWait must NOT trigger IS→CL fallback \ + (wallet-state mismatch is a hard failure)" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs index ba05137ec4d..feea66ab0ae 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up.rs @@ -1,195 +1,57 @@ -//! Top up an identity's credit balance. +//! Identity top-up convenience wrapper. +//! +//! The substantive top-up logic lives next to identity registration in +//! [`super::registration`] — both flows share the same funding +//! resolution, IS→CL fallback, and asset-lock cleanup machinery, so +//! keeping them in one module avoids duplication. +//! +//! This file just hosts the convenience wrapper `top_up_identity` +//! (defaults to wallet-balance funding) that delegates to the L2 +//! orchestrator `top_up_identity_with_funding`. -use std::time::Duration; - -use dpp::identity::accessors::IdentitySettersV0; use dpp::prelude::Identifier; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; use crate::error::PlatformWalletError; - -use crate::wallet::identity::types::funding::TopUpFundingMethod; +use crate::wallet::identity::types::funding::IdentityFunding; use super::*; -// --------------------------------------------------------------------------- -// Top-up -// --------------------------------------------------------------------------- - impl IdentityWallet { - /// Top up an existing identity's credit balance. + /// Top up an existing identity's credit balance from this wallet's + /// UTXOs. /// - /// Convenience wrapper that uses `FundWithWallet` funding. For other - /// funding methods, use [`top_up_identity_with_funding`](Self::top_up_identity_with_funding). + /// Convenience wrapper around + /// [`top_up_identity_with_funding`](Self::top_up_identity_with_funding) + /// for the common case (`IdentityFunding::FromWalletBalance`). /// /// # Arguments /// /// * `identity_id` - The identifier of the identity to top up. - /// * `topup_index` - An incrementing index distinguishing successive - /// top-ups for the same identity. /// * `amount_duffs` - Amount of Dash (in duffs) to add. - pub async fn top_up_identity( + /// * `asset_lock_signer` - External ECDSA signer that produces both + /// the funding-input P2PKH signatures during asset-lock build and + /// the consume-phase outer signature on the IdentityTopUp + /// transition. In Swift this is a `MnemonicResolverCoreSigner` + /// wrapping the Keychain resolver vtable. + pub async fn top_up_identity( &self, identity_id: &Identifier, - topup_index: u32, amount_duffs: u64, + asset_lock_signer: &AS, settings: Option, - ) -> Result<(), PlatformWalletError> { + ) -> Result<(), PlatformWalletError> + where + AS: ::key_wallet::signer::Signer + Send + Sync, + { self.top_up_identity_with_funding( identity_id, - TopUpFundingMethod::FundWithWallet { amount_duffs }, - topup_index, + IdentityFunding::FromWalletBalance { amount_duffs }, + asset_lock_signer, settings, ) - .await - } - - /// Top up an existing identity's credit balance with a specified funding method. - /// - /// # Funding methods - /// - /// * `UseAssetLock` - Use a pre-existing proof and private key directly. - /// * `FundWithWallet` - Build an asset lock from wallet UTXOs (default). - /// - /// # IS -> CL fallback - /// - /// See [`register_identity_with_funding`](Self::register_identity_with_funding) - /// for details on the IS -> CL fallback strategy. - pub async fn top_up_identity_with_funding( - &self, - identity_id: &Identifier, - funding: TopUpFundingMethod, - // TODO(platform-wallet): route `topup_index` through the - // derivation path for the top-up asset lock. Currently - // unused; the function derives from `identity_index` - // alone. - _topup_index: u32, - settings: Option, - ) -> Result<(), PlatformWalletError> { - // Retrieve the identity and its HD index from the manager. - let (identity, identity_index) = { - let wm = self.wallet_manager.read().await; - let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })?; - let manager = &info.identity_manager; - let identity = manager - .identity(identity_id) - .map(|m| m.identity.clone()) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - let index = manager - .identity_index(identity_id) - .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; - (identity, index) - }; - - // Step 1: Obtain the asset lock proof and private key. - let (asset_lock_proof, asset_lock_private_key) = match funding { - TopUpFundingMethod::UseAssetLock { proof, private_key } => (proof, private_key), - TopUpFundingMethod::FundWithWallet { amount_duffs } => { - use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; - let (proof, key, _out_point) = self - .asset_locks - .create_funded_asset_lock_proof( - amount_duffs, - 0, - AssetLockFundingType::IdentityTopUp, - identity_index, - ) - .await?; - (proof, key) - } - }; - - // Extract the outpoint before consuming the proof, in case we need to - // build a ChainLock proof for recovery. - let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); - - // Step 2: Submit the top-up state transition. - let user_fee_increase = settings.and_then(|s| s.user_fee_increase); - let new_balance = match identity - .top_up_identity( - &self.sdk, - asset_lock_proof, - &asset_lock_private_key, - user_fee_increase, - settings, - ) - .await - { - Ok(balance) => balance, - Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { - // IS-lock proof was rejected — try to upgrade to ChainLock. - if let Some(out_point) = proof_out_point { - tracing::warn!( - "IS-lock proof rejected for identity top-up (tx {}), \ - retrying with ChainLock proof", - out_point.txid - ); - let chain_proof = self - .asset_locks - .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) - .await?; - identity - .top_up_identity( - &self.sdk, - chain_proof, - &asset_lock_private_key, - user_fee_increase, - settings, - ) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity (ChainLock retry): {}", - e - )) - })? - } else { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity: {}", - e - ))); - } - } - Err(e) => { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity: {}", - e - ))); - } - }; - - // Update the identity's balance in the local manager and - // queue the snapshot so the new balance survives relaunch. - // - // `set_balance` on the inner DPP `Identity` doesn't know about - // the persister, so we drive it ourselves via - // `ManagedIdentity::snapshot_changeset` + `WalletPersister::store` - // (both `pub(crate)`, usable inside this crate). - { - let mut wm = self.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })?; - if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after top_up" - ); - } - } - } - + .await?; Ok(()) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs b/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs index b417ea4840e..dd6a3093709 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/funding.rs @@ -1,86 +1,81 @@ -//! Funding method enums for identity registration and top-up. +//! Funding source enum for identity registration and top-up. //! -//! These enums describe *how* an identity operation is funded, decoupling the -//! funding source from the identity lifecycle logic. +//! The single source of funding for any identity lifecycle operation +//! (register, top up) is [`IdentityFunding`]. The funded-but-not-yet- +//! consumed asset lock is the central concept — every variant ends up +//! resolved to `(AssetLockProof, DerivationPath)` before submission to +//! Platform. //! -//! ## Type overview +//! ## Historical note //! -//! * [`IdentityFunding`] — unified funding enum used by the new -//! `create_funded_asset_lock_proof` flow. Covers wallet-balance and -//! pre-existing asset locks. -//! * [`IdentityFundingMethod`] / [`TopUpFundingMethod`] — original per-operation -//! enums consumed by `register_identity_with_funding` and -//! `top_up_identity_with_funding`. Retained for backwards compatibility. +//! Earlier iterations carried two parallel funding enums +//! (`IdentityFundingMethod` / `TopUpFundingMethod`) consumed by +//! per-operation helpers. They were merged into [`IdentityFunding`] +//! once the registration and top-up high-level helpers grew identical +//! funding-resolution + IS→CL fallback shapes — at which point the +//! per-operation enums were dead weight. The merge happened in iter +//! 4 of the swift-funding-with-asset-lock series. -use dashcore::{OutPoint, PrivateKey}; -use dpp::prelude::AssetLockProof; +use dashcore::OutPoint; +use key_wallet::bip32::DerivationPath; -// ─── Unified funding enum ──────────────────────────────────────────────────── - -/// How to fund an identity operation (registration, top-up, etc.). +/// How to fund an identity operation (registration, top-up). /// -/// This is the *unified* enum consumed by -/// [`CoreWallet::create_funded_asset_lock_proof`](crate::wallet::core::CoreWallet::create_funded_asset_lock_proof). -/// It replaces the earlier pattern of having separate funding enums per -/// operation type. +/// Resolved by the high-level `register_identity_with_funding` / +/// `top_up_identity_with_funding` helpers into an +/// `(AssetLockProof, DerivationPath, OutPoint)` triple that the +/// `_with_signer` SDK methods can consume. The `OutPoint` is retained +/// for cleanup (so the tracked-asset-lock row can be removed on +/// success) and for IS→CL fallback (so the consumed lock can be +/// looked up by outpoint when the IS proof times out or is rejected). #[derive(Debug, Clone)] pub enum IdentityFunding { /// Build an asset lock from wallet UTXOs for the given amount. + /// + /// The helper picks the appropriate funding account + /// (`identity_registration` for register, `identity_topup` for top + /// up), builds the asset-lock tx, broadcasts it, waits for an + /// IS-lock proof, and falls back to ChainLock if the IS-lock times + /// out (300s) or is rejected at Platform. FromWalletBalance { /// Amount to lock (in duffs). amount_duffs: u64, }, - /// Resume from a tracked asset lock identified by its outpoint (txid + output index). + + /// Resume from a tracked asset lock identified by its outpoint + /// (txid + output index). /// - /// The asset lock must already be tracked by the [`AssetLockManager`]. - /// The manager will resume from whatever stage the lock is at (built, - /// broadcast, IS-locked, or chain-locked) and re-derive the private key. + /// The asset lock must already be tracked by the + /// [`AssetLockManager`](crate::wallet::asset_lock::manager::AssetLockManager). + /// The manager resumes from whatever stage the lock is at (built, + /// broadcast, IS-locked, or chain-locked) and re-derives the + /// credit-output derivation path; the signer-driven submission path + /// then passes that path back to the same signer when constructing + /// the IdentityCreate / IdentityTopUp transition. FromExistingAssetLock { /// The outpoint identifying the tracked asset lock (txid + output index). out_point: OutPoint, }, -} - -// ─── Per-operation funding enums (original API) ────────────────────────────── -/// Funding method for identity registration. -pub enum IdentityFundingMethod { - /// Use a pre-existing asset lock proof (e.g. one tracked by - /// [`CoreWallet::tracked_asset_locks`]). - UseAssetLock { - /// The asset lock proof (IS or CL). - proof: AssetLockProof, - /// The one-time private key from the asset lock payload. - private_key: PrivateKey, - }, - /// Build an asset lock from wallet UTXOs for the given amount (in duffs). + /// Use a pre-supplied asset lock proof + derivation path directly. /// - /// This is the default path used by the convenience wrapper. - FundWithWallet { - /// Amount to lock (in duffs). - amount_duffs: u64, - }, - // NOTE: FundFromAddresses (platform address funding, no asset lock) is - // intentionally omitted for now. It requires a different state transition - // type (`IdentityCreateFromAddressesTransition`) and a different signer - // (`Signer`), making it a substantially different code - // path. It can be added in a follow-up PR. -} - -/// Funding method for identity top-up. -pub enum TopUpFundingMethod { - /// Use a pre-existing asset lock proof. + /// The caller has already obtained the proof through some external + /// flow (e.g. an SDK-side broadcast that ran outside this wallet's + /// `AssetLockManager`) and just needs the registration / top-up + /// flow to submit it. No tracking, no fallback, no cleanup — the + /// caller owns the lifecycle. + /// + /// In practice this variant is used by callers that manage asset + /// locks via a sibling component (evo-tool's tasks, integration + /// tests, etc.). The Swift app's normal flow goes through + /// `FromWalletBalance` or `FromExistingAssetLock`. UseAssetLock { /// The asset lock proof (IS or CL). - proof: AssetLockProof, - /// The one-time private key from the asset lock payload. - private_key: PrivateKey, - }, - /// Build an asset lock from wallet UTXOs for the given amount (in duffs). - /// - /// This is the default path used by the convenience wrapper. - FundWithWallet { - /// Amount to lock (in duffs). - amount_duffs: u64, + proof: dpp::prelude::AssetLockProof, + /// Derivation path the credit-output P2PKH was built from. The + /// signer-driven submission path passes this to the asset-lock + /// signer when constructing the IdentityCreate / IdentityTopUp + /// transition. + derivation_path: DerivationPath, }, } diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs index 8a602a49ff6..05f652b6e51 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs @@ -15,5 +15,5 @@ pub use dashpay::{ ContactRequest, DashPayProfile, DashpayAddressMatch, EstablishedContact, PaymentDirection, PaymentEntry, PaymentStatus, ProfileUpdate, }; -pub use funding::{IdentityFunding, IdentityFundingMethod, TopUpFundingMethod}; +pub use funding::IdentityFunding; pub use key_storage::{DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData}; From 9d5e506ad6c39913d75f073e805a75a3969c99d1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 00:21:26 +0700 Subject: [PATCH 04/54] feat(swift-sdk): wrap signer-based FFI with MnemonicResolver lifetimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new MnemonicResolverCoreSigner FFI through ManagedPlatformWallet so identity registration, asset-lock proof creation, and Core sends all sign via a resolver vtable rather than passing private-key bytes across the FFI boundary. - ManagedPlatformWallet: `registerIdentityWithFunding(amountDuffs: identityIndex:identityPubkeys:signer:)` creates an internal `MnemonicResolver()` and uses `withExtendedLifetime((signer, coreSigner))` around the FFI call so ARC can't release the resolver while Rust still holds its handle. - ManagedAssetLockManager: `buildTransaction` and `createFundedProof` now take an external `MnemonicResolver` parameter and return a `derivationPath: String` instead of a `privateKey: Data`. The consume-phase signing happens in the next FFI call (`resume` doesn't need a signer at all). - ManagedCoreWallet.sendToAddresses: creates and lifetime-extends an internal `MnemonicResolver` for each call — keys never leave Swift, Core ECDSA happens atomically inside the vtable. - KeychainManager: split the duplicate-key insert into an explicit attribute-only `SecItemDelete` followed by `SecItemAdd`. Previously the delete query included `kSecValueData`, which Keychain interprets as a value filter, so the entry survived and `SecItemAdd` failed with `errSecDuplicateItem`. Kept the original `identity_privkey.` account naming — wallet-id isolation was out of scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AssetLock/ManagedAssetLockManager.swift | 75 ++++++++++++------- .../CoreWallet/ManagedCoreWallet.swift | 29 ++++--- .../ManagedPlatformWallet.swift | 48 ++++++++---- .../Security/KeychainManager.swift | 20 ++++- 4 files changed, 117 insertions(+), 55 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift index 6083eadef16..dcbd5b143e7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift @@ -51,16 +51,17 @@ public class ManagedAssetLockManager { public struct BuildResult { /// Serialized signed transaction. public let transaction: Data - /// 32-byte one-time private key. - public let privateKey: Data + /// Credit-output derivation path (string form). The signer + /// uses this path to produce the consume-phase signature. + public let derivationPath: String } /// Result of creating a funded asset lock proof. public struct FundedProofResult { /// Bincode-encoded AssetLockProof. public let proofBytes: Data - /// 32-byte one-time private key. - public let privateKey: Data + /// Credit-output derivation path (string form). + public let derivationPath: String /// 32-byte transaction ID. public let txid: Data } @@ -69,8 +70,8 @@ public class ManagedAssetLockManager { public struct ResumeResult { /// Bincode-encoded AssetLockProof. public let proofBytes: Data - /// 32-byte one-time private key. - public let privateKey: Data + /// Credit-output derivation path (string form). + public let derivationPath: String } // MARK: - Queries @@ -102,60 +103,72 @@ public class ManagedAssetLockManager { // MARK: - Build - /// Build an asset lock transaction. + /// Build an asset lock transaction. The caller supplies a + /// `MnemonicResolver` whose vtable backs every per-input Core + /// ECDSA signature; the credit-output private key is never + /// exposed across the FFI. The resolver must outlive this call. public func buildTransaction( amountDuffs: UInt64, accountIndex: UInt32 = 0, fundingType: FundingType, - identityIndex: UInt32 = 0 + identityIndex: UInt32 = 0, + resolver: MnemonicResolver ) throws -> BuildResult { var txBytesPtr: UnsafeMutablePointer? = nil var txLen: UInt = 0 - var privateKey: FFIByteTuple32 = // gitleaks:allow - (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + var pathPtr: UnsafeMutablePointer? = nil - try asset_lock_manager_build_transaction( - handle, amountDuffs, accountIndex, fundingType.rawValue, identityIndex, - &txBytesPtr, &txLen, &privateKey - ).check() + try withExtendedLifetime(resolver) { + try asset_lock_manager_build_transaction( + handle, amountDuffs, accountIndex, fundingType.rawValue, identityIndex, + resolver.handle, &txBytesPtr, &txLen, &pathPtr + ).check() + } guard let txPtr = txBytesPtr, txLen > 0 else { throw PlatformWalletError.unknown("FFI returned success but transaction buffer was empty") } defer { asset_lock_manager_free_tx_bytes(txPtr, txLen) } + defer { if let p = pathPtr { platform_wallet_string_free(p) } } let txData = Data(bytes: txPtr, count: Int(txLen)) - let keyData = withUnsafeBytes(of: &privateKey) { Data($0) } - return BuildResult(transaction: txData, privateKey: keyData) + let path = pathPtr.map { String(cString: $0) } ?? "" + return BuildResult(transaction: txData, derivationPath: path) } - /// Build, broadcast, and wait for an asset lock proof. + /// Build, broadcast, and wait for an asset lock proof. Signing + /// happens through the supplied `MnemonicResolver` — the + /// credit-output private key never crosses the FFI boundary. public func createFundedProof( amountDuffs: UInt64, accountIndex: UInt32 = 0, fundingType: FundingType, - identityIndex: UInt32 = 0 + identityIndex: UInt32 = 0, + resolver: MnemonicResolver ) throws -> FundedProofResult { var proofBytesPtr: UnsafeMutablePointer? = nil var proofLen: UInt = 0 - var privateKey: FFIByteTuple32 = // gitleaks:allow - (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + var pathPtr: UnsafeMutablePointer? = nil var txid: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) - try asset_lock_manager_create_funded_proof( - handle, amountDuffs, accountIndex, fundingType.rawValue, identityIndex, - &proofBytesPtr, &proofLen, &privateKey, &txid - ).check() + try withExtendedLifetime(resolver) { + try asset_lock_manager_create_funded_proof( + handle, amountDuffs, accountIndex, fundingType.rawValue, identityIndex, + resolver.handle, &proofBytesPtr, &proofLen, &pathPtr, &txid + ).check() + } guard let proofPtr = proofBytesPtr, proofLen > 0 else { throw PlatformWalletError.unknown("FFI returned success but proof buffer was empty") } defer { asset_lock_manager_free_proof_bytes(proofPtr, proofLen) } + defer { if let p = pathPtr { platform_wallet_string_free(p) } } + let path = pathPtr.map { String(cString: $0) } ?? "" return FundedProofResult( proofBytes: Data(bytes: proofPtr, count: Int(proofLen)), - privateKey: withUnsafeBytes(of: &privateKey) { Data($0) }, + derivationPath: path, txid: withUnsafeBytes(of: &txid) { Data($0) } ) } @@ -163,6 +176,9 @@ public class ManagedAssetLockManager { // MARK: - Resume /// Resume a tracked asset lock from whatever stage it's at. + /// Resume only re-derives the proof + credit-output path; signing + /// the consume transaction happens in the next FFI call, which + /// is where the resolver plugs in. No signer parameter needed. public func resume( txid: Data, vout: UInt32 = 0, @@ -176,8 +192,7 @@ public class ManagedAssetLockManager { var proofBytesPtr: UnsafeMutablePointer? = nil var proofLen: UInt = 0 - var privateKey: FFIByteTuple32 = // gitleaks:allow - (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + var pathPtr: UnsafeMutablePointer? = nil var txidTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) @@ -189,17 +204,19 @@ public class ManagedAssetLockManager { try asset_lock_manager_resume( handle, &txidTuple, vout, timeoutSeconds, - &proofBytesPtr, &proofLen, &privateKey + &proofBytesPtr, &proofLen, &pathPtr ).check() guard let proofPtr = proofBytesPtr, proofLen > 0 else { throw PlatformWalletError.unknown("FFI returned success but proof buffer was empty") } defer { asset_lock_manager_free_proof_bytes(proofPtr, proofLen) } + defer { if let p = pathPtr { platform_wallet_string_free(p) } } + let path = pathPtr.map { String(cString: $0) } ?? "" return ResumeResult( proofBytes: Data(bytes: proofPtr, count: Int(proofLen)), - privateKey: withUnsafeBytes(of: &privateKey) { Data($0) } + derivationPath: path ) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index ba02dbc3c39..bfb23a34869 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -109,18 +109,27 @@ public class ManagedCoreWallet { let cStrings = recipients.map { ($0.address as NSString).utf8String } let amounts = recipients.map { $0.amountDuffs } + // Resolver-backed signer owns mnemonic access for the lifetime + // of this call. Each Core ECDSA signature happens atomically + // inside the resolver vtable (mnemonic fetched, key derived, + // digest signed, buffers zeroed) — no priv key leaves Swift. + let resolver = MnemonicResolver() + try cStrings.withUnsafeBufferPointer { addrBuf in try amounts.withUnsafeBufferPointer { amountBuf in - try core_wallet_send_to_addresses( - handle, - accountType.rawValue, - accountIndex, - addrBuf.baseAddress, - amountBuf.baseAddress, - UInt(recipients.count), - &txBytesPtr, - &txLen - ).check() + try withExtendedLifetime(resolver) { + try core_wallet_send_to_addresses( + handle, + accountType.rawValue, + accountIndex, + addrBuf.baseAddress, + amountBuf.baseAddress, + UInt(recipients.count), + resolver.handle, + &txBytesPtr, + &txLen + ).check() + } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 23d806eed66..f250969613c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2365,9 +2365,17 @@ extension ManagedPlatformWallet { let handle = self.handle let signerHandle = signer.handle let pubkeys = identityPubkeys + // Create a `MnemonicResolver` owned for the lifetime of the + // FFI call — Rust constructs a `MnemonicResolverCoreSigner` + // from this handle to sign the asset-lock proof's + // credit-spending signature on the IdentityCreate transition. + // The resolver's vtable callback fetches the mnemonic from + // Keychain, derives the priv key at the credit-output path, + // signs the digest, and zeroes — atomic per call. No priv + // key ever lives in Rust memory across operations. + let coreSigner = MnemonicResolver() return try await Task.detached(priority: .userInitiated) { () -> (Identifier, ManagedIdentity) in - _ = signer var idTuple: ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -2380,21 +2388,31 @@ extension ManagedPlatformWallet { var outManagedHandle: Handle = NULL_HANDLE // Pin each pubkey buffer simultaneously via the existing // helper, then hand the assembled row array to the FFI. + // + // `withExtendedLifetime` is the canonical Swift idiom for + // "keep this ARC-managed object alive across an FFI call + // that captures a raw handle to it". `_ = signer` / + // `_ = coreSigner` is folklore; the optimizer may elide + // the discard in -O builds, releasing the resolver mid- + // FFI-call → use-after-free in the vtable callback. let pubkeyBuffers: [Data] = pubkeys.map { $0.pubkeyBytes } - let result = ManagedPlatformWallet.withPubkeyFFIArray( - pubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - platform_wallet_register_identity_with_funding_signer( - handle, - amountDuffs, - identityIndex, - ffiRowsPtr, - UInt(ffiRowsCount), - signerHandle, - &idTuple, - &outManagedHandle - ) + let result = withExtendedLifetime((signer, coreSigner)) { + ManagedPlatformWallet.withPubkeyFFIArray( + pubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_register_identity_with_funding_signer( + handle, + amountDuffs, + identityIndex, + ffiRowsPtr, + UInt(ffiRowsCount), + signerHandle, + coreSigner.handle, + &idTuple, + &outManagedHandle + ) + } } try result.check() // Copy the 32-byte tuple into a Data via withUnsafeBytes. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index b096b2d8043..7dcc39bfb77 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -624,7 +624,25 @@ extension KeychainManager { // Delete-then-add pattern lets the row settle into the new // `kSecValueData` + `kSecAttrGeneric` without fighting // SecItemUpdate's attribute-preservation quirks. - SecItemDelete(query as CFDictionary) + // + // The DELETE query must be ATTRIBUTE-ONLY: Apple Keychain + // treats `kSecValueData` and `kSecAttrGeneric` (the value + // payloads) as *value filters* on delete queries — including + // them would only delete rows whose data ALREADY equals the + // new bytes, which by definition is never true on an update. + // Without this fix, every prior failed attempt left a stale + // row that we could neither delete (data mismatch) nor add + // (uniqueness conflict on service+account → errSecDuplicateItem). + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + ] + if let accessGroup = accessGroup { + deleteQuery[kSecAttrAccessGroup as String] = accessGroup + } + SecItemDelete(deleteQuery as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess ? account : nil } From 8a57e88260a79126b0e87f47cc90ce2a796e0bd3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 00:21:37 +0700 Subject: [PATCH 05/54] feat(SwiftExampleApp): Core-funded identity registration in CreateIdentityView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateIdentityView gains a Core-account branch alongside the existing asset-lock-proof flow. When the user picks a Core wallet account with a sufficient balance, the view validates the funding amount, calls `ManagedPlatformWallet.registerIdentityWithFunding( amountDuffs:identityIndex:identityPubkeys:signer:)`, and lets the Rust side build → broadcast → await IS-lock → fall back to chainlock → register → clean up. The credit-output private key never crosses the FFI; the wallet's MnemonicResolver signs each Core ECDSA digest atomically. - Plan document (CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md, Draft 9) captures the full spec, the 7-iteration design history, adversarial review outcomes, and the open P0 follow-up about SPV event routing (chainlock signatures not yet propagating to the wallet tx-record context — tracked separately). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 1069 +++++++++++++++++ .../Views/CreateIdentityView.swift | 338 +++++- 2 files changed, 1350 insertions(+), 57 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md new file mode 100644 index 00000000000..15ea8350ad8 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -0,0 +1,1069 @@ +# Create Identity from Core Funds — Plan (Draft 9) + +Status: **iter 1 + 2 + 4 done. Testnet validation hit an SPV event-routing concern (separate investigation). iter 3 (SwiftData mirror) and iter 5 (resume picker) still pending.** +Branch: `feat/swift/funding-with-asset-lock` +Target: SwiftExampleApp, testnet validation first. + +**Draft 7 update (mid-iter-1 discovery)**: testnet validation of iter 1 +revealed the asset-lock builder dies with `"Cannot sign with watch-only +wallet"` — misleading error from +`key-wallet::asset_lock_builder.rs:188` which collapses both +`WalletType::WatchOnly` AND `WalletType::ExternalSignable` errors into +the same `WatchOnlyWallet` variant. Investigation confirmed: + +- `persistence.rs:1688` already correctly creates wallets as + `ExternalSignable` on reload (the comment about "watch-only" elsewhere + is stale). +- The real gap: `build_asset_lock_transaction` calls the soft-only + `build_asset_lock` instead of `build_asset_lock_with_signer`. No Core + signer is plumbed through any layer of platform-wallet / FFI / Swift. +- The existing single `signer` parameter on + `register_identity_with_funding_external_signer` is only for Platform + state-transition signing — never reaches Core asset-lock signing. + +**Iter 2 is now "Core signer plumbing"** — was "SwiftData mirror". +SwiftData mirror moves to iter 3. See § Iter 2 — Core signer plumbing. + +This document captures the plan for wiring "Create a Platform +identity using an asset-lock proof, funded from Core wallet +UTXOs" in `CreateIdentityView`. Delivered in seven incremental +iterations — each is testable end-to-end on testnet before the +next one starts, so we can stop, redirect, or expand scope +after every step. + +The ordering prioritizes user-visible progress: iter 1 ships a +working (if minimal) feature, iter 2 + 3 layer on the SwiftData +mirror and the stage-aware progress bar, then iter 4 does the +Rust refactor (which is invisible to users but fixes a leak and +unlocks resume). Iter 5 ships resume. + +## Goal + +User opens the app, picks a Core account from a wallet that has +testnet DASH UTXOs, picks an identity registration index, hits +"Create". Rust builds the asset-lock funding tx, broadcasts it, +waits for the instant-send lock, registers the identity on +Platform, persists the new `PersistentIdentity` + identity auth +keys. Swift only marshals the call, mirrors the tracked asset +lock to SwiftData (from iter 2 onward), and surfaces stage +progress + errors (from iter 3 onward). + +Two related modes covered (split across iter 1 and iter 5): + +- **Fund from wallet** — build a new asset lock from wallet + UTXOs. Delivered iter 1. +- **Fund from unused asset lock** — resume a previously built + asset lock by outpoint (recovery path after a crash, network + error, or dismissed flow that left funds locked). Delivered + iter 5, depends on the Rust refactor in iter 4. + +## Testnet prerequisite + +Wallet inside the app must hold testnet DASH. Two options: + +1. **Fresh wallet**: create one inside the app (network defaults + to testnet — `AppState.swift:51`), copy first receive + address, fund from . +2. **Existing funded testnet mnemonic**: import via the wallet- + import flow. Needs ≥ `assetLockMinimum + fee + headroom` + spendable duffs. + +The mnemonic never crosses the FFI boundary into Swift (see +`packages/swift-sdk/CLAUDE.md`). All derivation, UTXO +selection, tx construction, broadcast, instant-lock wait, and +identity registration happen Rust-side. + +## Rust pipeline (post-iter-4 shape) + +Iter 1, 2, 3 use the function as it exists today +(`register_identity_with_funding_external_signer`). Iter 4 +refactors it into the two-layer factoring below and adds +resume + cleanup. From iter 4 onward, the pipeline shape is: + +**L2 — `register_identity_with_funding`** (renamed wallet-managed function): + +```rust +async fn register_identity_with_funding>( + funding: IdentityFunding, // FromWalletBalance | FromExistingAssetLock | UseAssetLock + identity_index: u32, + keys_map: BTreeMap, + signer: &S, + settings: Option, +) -> Result +``` + +Dispatch: + +- `IdentityFunding::FromWalletBalance { amount_duffs }` → + `asset_locks.create_funded_asset_lock_proof()` builds tx → + broadcasts → waits for IS-lock → returns `(proof, key, + out_point)`. +- `IdentityFunding::FromExistingAssetLock { out_point }` → + `asset_locks.resume_asset_lock()` picks up at whatever stage + the tracked lock left off at and re-derives the one-time + private key. +- `IdentityFunding::UseAssetLock { proof, private_key }` → + passes through (no asset-lock build/resume; outpoint derived + via `out_point_from_proof` for cleanup). + +All three paths submit via L1 (`register_identity_with_signer`) +with IS→CL fallback, then add to `IdentityManager`, then +`remove_asset_lock` on success. + +### Stages (for the progress bar) + +Defined as `AssetLockStatus` at +`packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs:17`: + +| Stage | Description | Persister fired | +|---|---|---| +| `Built` | Tx constructed locally | ✅ `build.rs:316` | +| `Broadcast` | Sent to Core network | ✅ `build.rs:330` | +| `InstantSendLocked` | IS-locked (usable to register) | ✅ `build.rs:353` | +| `ChainLocked` | Mined into a chain-locked block | ✅ `recovery.rs:238` | +| `RegisteringOnPlatform` | Platform state-transition in flight | — (UI-only label, not in Rust enum) | +| `Done` | Identity registered + tracked lock removed | — (signaled by row deletion) | + +Every status transition emits a changeset to the persister. +FFI snapshot: `platform_wallet_tracked_asset_locks_list()` at +`packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs:258`; +Swift wrapper: `PlatformWalletManagerDiagnostics.swift:162` +(`trackedAssetLocks(for:)` → `[TrackedAssetLockSnapshot]`). + +## Persistence on the Swift side — the gap + +Rust persists tracked asset locks robustly. Swift currently does +**not** mirror them to SwiftData — queryable only via the FFI +snapshot. Implications: + +- Cross-launch recovery works (Rust reloads the lock on wallet + init), but the SwiftUI explorer surfaces don't see a tracked + lock until something queries the FFI. +- The progress-bar UI cannot reactively follow a stage + transition via `@Query`. +- The explorers have no row for tracked asset locks beyond a + count. + +**Resolved in iter 2.** From iter 2 onward, a `PersistentAssetLock` +SwiftData model mirrors `TrackedAssetLock` via a new FFI +persister callback. + +## Existing UI to extend + +| Surface | File:line | Current state | +|---|---|---| +| `CreateIdentityView` — Source Wallet + Account picker | `CreateIdentityView.swift` whole file | Platform Payment path wired; Core / CoinJoin / walletless stubbed | +| `CreateIdentityView` — "Fund from unused Asset Lock" picker entry | `CreateIdentityView.swift:200-201` | Picker exists; submit path stub | +| `StorageExplorerView` | `StorageExplorerView.swift:5,27-78` | 18 Persistent* models listed; no AssetLock row | +| `WalletMemoryExplorerView` — count only | `WalletMemoryExplorerView.swift:166,368` | Shows "N asset locks" count; no drill-down | + +--- + +## Delivery iterations + +Each iteration ships independently. Iter 1 is the smallest path +to "wallet-balance identity creation works"; refactors and +polish layer on after. + +### Iter 1 — Wire existing function from Swift (Swift only, no Rust changes) + +**Goal**: prove the wallet-balance path works end-to-end on +testnet without touching Rust. Smallest possible change. + +The existing `registerIdentityWithFunding(amountDuffs:identityIndex:identityPubkeys:signer:)` +at `ManagedPlatformWallet.swift:2356` already wraps +`platform_wallet_register_identity_with_funding_signer`, which +calls `register_identity_with_funding_external_signer`. That +function's `FundWithWallet { amount_duffs }` branch is exactly +the wallet-balance path. All the infrastructure exists. + +**Steps**: + +1. Detect when the chosen `PersistentAccount` is a Core / + CoinJoin account vs Platform Payment in + `CreateIdentityView.submit()`. +2. Add a funding-amount UI field (default 100,000 duffs / 0.001 + DASH). Validate against the selected account's spendable + balance. +3. On submit: + - `prePersistIdentityKeysForRegistration(identityIndex:, + keyCount: 3, network:)` → `[(path, pubkeyBytes)]`. + - Map to `[ManagedPlatformWallet.IdentityPubkey]`: first key + `securityLevel = .master`, remaining `.high`. + - `registerIdentityWithFunding(amountDuffs:, identityIndex:, + identityPubkeys:, signer: KeychainSigner)`. +4. Generic in-flight spinner with "Registering identity…" + message — no stage UI yet. +5. Disable the "Fund from unused Asset Lock" picker option + (resume support arrives iter 4 + 5). +6. Manual testnet validation: fund wallet, create identity at + index 0 with 100,000 duffs, verify on + . + +**Known limitations (deliberately deferred)**: + +- **Tracked asset locks leak in Rust state on success.** + `_out_point` is dropped at `registration.rs:105`. Silent in + iter 1 (no Swift mirror), surfaces as clutter in iter 2-3 + (rows accumulate but progress bar still works), fixed in + iter 4. +- **No stage progress bar.** Generic spinner only. Fixed in + iter 3. +- **No resume path.** Picker option exists but submit is + stubbed. Fixed in iter 5. +- **No crash recovery UI.** If the app dies mid-flow, Rust + retains the tracked lock internally but Swift cannot see or + act on it. Fixed in iter 5 (resume picker becomes the + recovery affordance). + +--- + +### Iter 2 — Core signer plumbing (unblocks asset-lock signing for ExternalSignable wallets) + +**Goal**: pipe Core ECDSA signing through KeychainSigner so that +asset-lock-funded identity operations work on wallets where the seed +isn't in Rust (every wallet reloaded from persister state — today's +default via `Wallet::new_external_signable`). + +**Two distinct Core-side signing operations** in the asset-lock-funded +identity flow: + +1. **BUILD phase** — sign each UTXO input of the asset-lock tx. Today + uses soft `build_asset_lock(wallet, …)` which calls + `wallet.root_extended_priv_key()` and fails for ExternalSignable. + Fix: switch to `build_asset_lock_with_signer(wallet, …, signer)` and + pass our Core signer. +2. **CONSUME phase** — sign the asset-lock-proof's credit-spending + signature on the IdentityCreate state transition. Today uses + `state_transition.sign_by_private_key(asset_lock_proof_private_key, + ECDSA_HASH160, bls)` at `rs-dpp::identity_create_transition/v0/ + v0_methods.rs:78` — requires raw `&[u8]` private key. Fix: add a + sibling `sign_with_signer(path, signer)` to `StateTransition` and + route through it. + +The CONSUME-phase fix lives in rs-dpp + rs-sdk and requires upstream +additions there (purely additive — old paths stay). + +**The same gap exists in IdentityTopUp via asset-lock.** Same shape +fix applied to `top_up_identity` family in rs-sdk + rs-dpp's top-up +transition. ~80 LoC additional, same PR. + +**Naming convention** (sibling functions, not rename of existing): + +| Layer | Existing function (now explicit) | New sibling | +|---|---|---| +| rs-dpp `IdentityCreateTransitionV0` | `try_from_identity_with_signer_and_private_key` (renamed from `try_from_identity_with_signer`) | `try_from_identity_with_signers` (plural — both args are signers) | +| rs-dpp `IdentityTopUpTransitionV0` | same rename pattern | new `_with_signers` sibling | +| rs-dpp `StateTransition` | `sign_by_private_key` (keep — already explicit) | new `sign_with_signer` | +| rs-sdk `PutIdentity::put_to_platform` | `put_to_platform_with_private_key` | `put_to_platform_with_signer` (singular — only one new signer added) | +| rs-sdk `BroadcastNewIdentity::broadcast_request_for_new_identity` | `..._with_private_key` | `..._with_signer` | +| rs-sdk internal `put_identity_with_asset_lock` | `put_identity_with_asset_lock_and_private_key` | `put_identity_with_asset_lock_and_signer` | + +**Plural at rs-dpp, singular at rs-sdk** because at rs-dpp both +parameters are visible signers; at rs-sdk the identity signer is +implicit (always present) and we're adding one new signer. + +**Reuse the existing mnemonic-resolver pattern — no new Swift signer.** +KeychainSigner already vends a `MnemonicResolverHandle` for Platform- +address signing today. The Core-side signer (`MnemonicResolverCoreSigner`, +Rust-only) wraps that same handle and implements +`key_wallet::signer::Signer`. Each signing call is atomic — derive + +sign + zero inside a single FFI round-trip, identical security profile +to today's Platform-address signing. **No private key ever lives in +Rust memory across operations.** + +**The gap surfaced during iter 1 testnet validation**: the asset-lock +builder uses the soft-only `build_asset_lock` (`asset_lock/build.rs:82`) +which requires `wallet.root_extended_priv_key()`. That returns an error +for both `WatchOnly` and `ExternalSignable` wallet types — collapsed +into the misleading "Cannot sign with watch-only wallet" error from +`key-wallet::asset_lock_builder.rs:188`. The sibling +`build_asset_lock_with_signer` exists upstream but has zero callers in +this repo. + +**Steps**: + +**Step 1 — Delete `VTableCoreSigner`** ✅ **DONE** (`cargo check -p rs-sdk-ffi` clean) +- The trampoline at `packages/rs-sdk-ffi/src/core_signer.rs` (671 LoC, + 7 tests) was an over-engineered generic vtable bridge. We don't need + generic external signer flexibility — KeychainSigner-via-mnemonic- + resolver is the only signer we have. +- Delete the file, remove module export in `lib.rs`. + +**Step 1b — Add `MnemonicResolverCoreSigner`** ✅ **DONE** (332 LoC, 5 tests passing) at +`packages/rs-platform-wallet-ffi/src/mnemonic_resolver_core_signer.rs`: +- Holds a `MnemonicResolverHandle` (raw FFI handle into the Swift + KeychainSigner's resolver) + network. +- Implements `key_wallet::signer::Signer` with: + - `supported_methods()` returns `&[SignerMethod::Digest]`. + - `sign_ecdsa(path, sighash)`: resolves mnemonic via handle, derives + Core priv key at `path`, signs the digest with + `dpp::dashcore::signer::sign(sighash, &secret_bytes)`, zeroes the + key buffer, returns `(Signature, PublicKey)`. Mirrors the body of + `dash_sdk_sign_with_mnemonic_resolver_and_path` lines 149-234. + - `public_key(path)`: same flow but skips signing — just returns the + derived compressed pubkey. +- **Atomic per-call**: each method call is one derive + (sign|peek) + + zero round-trip. No priv key persists across method calls or across + FFI boundary. + +**Step 2 — `rs-dpp`: add `StateTransition::sign_with_signer`** ✅ **DONE** at `state_transition/mod.rs:1291`. Byte-parity test at `:3257` passing. `cargo check -p dpp` clean across all feature combos. 908 lib tests pass. +- Sibling to `sign_by_private_key` at + `packages/rs-dpp/src/state_transition/mod.rs:1206`. +- Signature: + ```rust + pub async fn sign_with_signer( + &mut self, + path: &DerivationPath, + signer: &S, + ) -> Result<(), ProtocolError> + ``` +- Body: compute `signable_bytes()`, apply same digest pre-image as + existing ECDSA path, call `signer.sign_ecdsa(path, digest)`, + serialize the resulting signature and call `set_signature(...)`. +- **Correctness check up front**: verify what `signer::sign(&data, + private_key)` (currently used at line 1226) does to `data` before + signing (raw / SHA256 / double_sha256). New `sign_with_signer` MUST + apply the same transform so signatures verify byte-identically. + +**Step 3 — `rs-dpp::IdentityCreateTransitionV0`: rename + new sibling** ✅ **DONE** at `v0_methods.rs:38` (renamed) and `:90` (new). Trait + outer-enum dispatcher updated. +- File: `packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/v0/v0_methods.rs:38`. +- Rename `try_from_identity_with_signer` → + `try_from_identity_with_signer_and_private_key` (no behavior change). +- Add new `try_from_identity_with_signers(...)`: + - `IS: Signer` — same as old, signs per-key witnesses. + - `AS: key_wallet::signer::Signer` — signs the asset-lock-proof line. + - New parameter: `asset_lock_proof_path: DerivationPath` — where AS + should sign. + - Body: identical to legacy until line 78; replace + `state_transition.sign_by_private_key(asset_lock_proof_private_key, + ECDSA_HASH160, bls)?` with + `state_transition.sign_with_signer(&asset_lock_proof_path, + asset_lock_signer).await?`. +- Update the trait `IdentityCreateTransitionMethodsV0` and its + dispatcher in `methods/mod.rs` to expose both functions. + +**Step 4 — `rs-dpp::IdentityTopUpTransitionV0`: same shape fix** ✅ **DONE** at `v0_methods.rs:29` (renamed → `_with_private_key`) and `:61` (new `_with_signer`). +- Top-up has the same asset-lock-private-key signing pattern. Mirror + Step 3 in that transition module. + +**Step 5 — `rs-sdk::PutIdentity` + helpers** ✅ **DONE** in `put_identity.rs`: renamed legacy methods to `_with_private_key`, added `put_to_platform_with_signer` (`:71`/`:187`) + `_and_wait_for_response_with_signer` (`:90`/`:212`). Helper `put_identity_with_asset_lock_and_signer` at `:308`. Also propagated through `rs-sdk-ffi`, `wasm-sdk`, `rs-drive-abci`, `strategy-tests` (50+ call-site renames). Tests: 117/117 pass. +- File: `packages/rs-sdk/src/platform/transition/put_identity.rs`. +- Rename existing trait methods `put_to_platform` / + `put_to_platform_and_wait_for_response` → + `put_to_platform_with_private_key` / `..._and_wait_for_response_with_private_key`. +- Add new sibling methods `put_to_platform_with_signer` / + `put_to_platform_and_wait_for_response_with_signer`. Same shape but + take asset-lock signer + path instead of `asset_lock_proof_private_key`. +- Rename internal helper `put_identity_with_asset_lock` → + `put_identity_with_asset_lock_and_private_key`. Add new + `put_identity_with_asset_lock_and_signer`. + +**Step 6 — `rs-sdk::BroadcastNewIdentity` mirror** ✅ **DONE** in `broadcast_identity.rs`: `..._with_private_key` (rename) at `:97`/`:129`, new `..._with_signer` at `:114`/`:153`. Also propagated TopUpIdentity in `top_up_identity.rs` (`top_up_identity_with_signer` at `:39`/`:79`). +- File: `packages/rs-sdk/src/platform/transition/broadcast_identity.rs`. +- Rename `broadcast_request_for_new_identity` → + `broadcast_request_for_new_identity_with_private_key`. Add new + `broadcast_request_for_new_identity_with_signer`. +- Both top-up entry points get the same treatment. + +**Step 7 — `rs-platform-wallet::asset_lock/build.rs`** ✅ **DONE**: +- Change `build_asset_lock_transaction` (line 40) to take a Core + signer parameter (`&S where S: key_wallet::signer::Signer`). +- Replace soft `build_asset_lock(wallet, account_index, fundings, fee)` + at line 82 with `build_asset_lock_with_signer(wallet, + account_index, fundings, fee, signer)`. +- Result type changes: `AssetLockCreditKeys::Public(Vec<(PublicKey, + DerivationPath)>)` instead of `Private(Vec<[u8;32]>)`. Plumb the + `(pubkey, path)` tuple through the call chain. +- Update the existing soft-path branch handling (delete it — soft path + cannot work for ExternalSignable, which is now the universal mode). + +**Step 8 — `rs-platform-wallet::wallet/core/broadcast.rs`** ✅ **DONE**: +- `send_to_addresses` (line 38) takes a Core signer too. Swap + `build_signed(wallet, …)` (line 120) → `build_signed(signer, …)`. + +**Step 9 — `rs-platform-wallet::identity/network/registration.rs`** ✅ **DONE**: +- `register_identity_with_funding_external_signer` (line 59) takes + asset-lock signer + identity-registration path. Routes through new + `put_identity_with_asset_lock_and_signer`. +- Same for top-up siblings. + +**Step 10 — `rs-platform-wallet-ffi`** ✅ **DONE**: +- Extend `platform_wallet_register_identity_with_funding_signer` + (`identity_registration_funded_with_signer.rs`) with a + `mnemonic_resolver_handle: *mut MnemonicResolverHandle` parameter + (NOT a new SignerHandle). Same handle type as Platform-address + signing already uses. Inside the FFI, construct + `MnemonicResolverCoreSigner` from the handle and pass to the + platform-wallet API. +- Same for `core_wallet_send_to_addresses` (used by SendViewModel). + +**Step 11 — Swift `KeychainSigner`** ✅ **DONE** — confirmed no new code needed. `MnemonicResolver` (the existing class at `MnemonicResolverAndPersister.swift`) already vends the handle the FFI consumes. Just pass `MnemonicResolver().handle` through to the FFI. +- `KeychainSigner` already vends `mnemonicResolverHandle: MnemonicResolverHandle` + used by Platform-address signing. No new property, no new vtable. +- The architectural rule "no private keys outside Swift" is preserved + because every `MnemonicResolverCoreSigner` method does atomic + derive+sign+zero — same security profile as today's + Platform-address path. + +**Step 12 — Swift call sites** ✅ **DONE**: +- `ManagedPlatformWallet.registerIdentityWithFunding(...)`: pass the + existing `keychainSigner.mnemonicResolverHandle` to the extended + FFI alongside the existing identity signer handle. +- `SendViewModel`: pass the same handle to the Core-side send FFI. +- `CreateIdentityView.submitCoreFunded` updates call site + mechanically. + +**Step 13 — Validation** ⚠️ **PARTIAL** — Core signer pipeline validated end-to-end on testnet (asset-lock tx confirmed on chain, our signer correctly signed UTXO inputs), but the wait-for-proof poll loop times out because **SPV→wallet event routing isn't delivering IS-lock / chain-lock context updates to `bip44_account.transactions()`**. The Swift app shows the tx as "Confirmed" but the asset-lock manager's tracked status stays at `Broadcast`. Diagnosed as either testnet masternode silence or a regression in dash-spv → wallet integration (likely from recent rust-dashcore bumps). **Iter 4's auto IS→CL fallback was triggered but ALSO timed out** because the chain-lock event never propagated to our poll either. See § Iter 5 / SPV event-routing follow-up. +- `cargo check` clean across rs-dpp, dash-sdk, platform-wallet, + platform-wallet-ffi after each layer lands. +- xcframework rebuild. +- Re-run testnet Core-funded identity creation → succeeds. +- Test normal Core send → succeeds. +- Optionally: test top-up via asset-lock if iter 1 isn't blocked on it. + +**Step 14 — Fix the misleading error string** (upstream, follow-up) +- `key-wallet::asset_lock_builder.rs:188` collapses both `WatchOnly` + and `ExternalSignable` errors into `WatchOnlyWallet`. Distinguish + them. Cosmetic / debuggability. Out of this PR. + +--- + +### Review findings (post-implementation, pre-testnet) + +Three reviewers audited the iter 2 implementation: a crypto/security +auditor, an adversarial reviewer, and a Rust quality engineer. Summary: + +**No critical findings.** Byte-parity of `sign_with_signer` vs the +legacy private-key path is confirmed (same `double_sha`, same +RFC6979, same low-s, same compact-65 framing — pinned by the test +at `rs-dpp::state_transition::mod.rs:3257`). Path equality between +build-phase and consume-phase is structurally enforced. Recovery-id +brute force is correct. No double-hashing. Swift mnemonic plaintext +is XOR-masked + `memset_s`-zeroed. + +#### ✅ Fixed in this session + +- **Adversarial P0 #4** — `_ = coreSigner` lifetime folklore. + Replaced with `withExtendedLifetime((signer, coreSigner)) { ... }` + in `ManagedPlatformWallet.swift:2376`. `_ = x` is not a Swift + language guarantee; the optimizer may release in `-O` builds + causing UAF in the vtable callback. + +#### 🔥 Post-testnet (mechanical fixes worth landing in this PR) + +- **Crypto H3** — ✅ **DONE in iter 4** — `register_identity_with_funding` + (new merged L2) calls `remove_asset_lock` on success. Hygiene + contract restored. + +- **Rust-quality #2** — `MnemonicResolverCoreSigner::Error` is + `String`. Replace with a typed `enum + MnemonicResolverSignerError { NullHandle, NotFound, + BufferTooSmall, ResolverFailed(i32), InvalidUtf8, + InvalidMnemonic, DerivationFailed(String), InvalidScalar }` so + callers can discriminate "user has no mnemonic in Keychain" + from "FFI buffer overflow". + +- **Rust-quality #3** — `StateTransition::sign_with_signer` maps + signer errors to `ProtocolError::Generic`. Should use a more + specific variant (e.g. new `ProtocolError::ExternalSignerError`) + so the recovery-id-mismatch case (invariant violation by a + conformant signer) is distinguishable from a real signing + failure. + +- **Rust-quality #9** — Inconsistent `Send + Sync` bounds across + `register_identity_with_funding_external_signer`, + `register_identity_with_signer`, `funded_register_identity` in + `rs-platform-wallet::registration.rs`. Spell `Send + Sync` on + both signer generic params everywhere for forward-compat with + future `spawn`-driven refactors. + +- **Crypto H4** — `MnemonicResolverCoreSigner.network` field is + decorative (derivation is path-driven, not network-driven). Add + a debug assertion in the constructor that `network == + wallet.network()`. Free safety net for the FFI call site, which + already pulls both values. + +#### 🛠 Polish (worth a follow-up PR, not blocking) + +- **Adversarial P1 #6** — `build.rs:117-132` uses + `keys.drain(..).next()` on `AssetLockCreditKeys::Public(keys)`. + Latent bug if a future change emits >1 credit output: the first + path is silently used, signature mismatch on the others, cascade + lands as a consensus rejection. Add `match keys.len() { 1 => + ..., n => return Err(...) }` boundary check. + +- **Rust-quality #6** — `try_from_identity_with_signer_and_private_key` + and `try_from_identity_with_signers` bodies are near-duplicates. + Extract a shared helper for the per-key-witness signing logic + (lines 47-76 / 104-133 are identical). Reduces drift. + +- **Rust-quality #11** — `MnemonicResolver()` in + `ManagedPlatformWallet.registerIdentityWithFunding` should accept + an injectable `storage: WalletStorage = WalletStorage()` parameter + for test parity with `prePersistIdentityKeysForRegistration` + (which already does this). + +#### 📌 Architectural / longer-term + +- **Adversarial P0 #1** — Wallet-manager write lock is held across + the entire signer-driven build path + (`rs-platform-wallet::asset_lock/build.rs:66-102`). The signer + calls back into Swift Keychain, which on iOS can block for tens + of seconds (Face ID prompts). The lock blocks SPV sync, balance + reads, persister flushes, top-up flows. Architectural fix: + release the write lock before invoking the signer; pass derived + material into the inner builder. **Defer until iter 1 + testnet-validation succeeds** — fixing it changes a load-bearing + control-flow shape mid-test cycle. + +- **Adversarial P1 #3** — Funding-account derivation index + advances inside `peek_next_funding_address` before the asset-lock + build can fail. If the signer errors (Keychain locked, user + cancels biometric), the wallet leaks a derivation slot per + attempt, drifting toward gap-limit (~20). Verify whether + `next_address(_, false)` is idempotent for not-yet-used entries; + if strictly-advancing, add transactional rollback. + +- **Adversarial P1 #7** — `block_on_worker` at + `rs-platform-wallet-ffi::runtime.rs:55` uses `.expect("tokio + worker panicked")` on `JoinError`. Pre-existing, but the new + resolver surface widens the panic-source space (user-driven + Keychain failures, malformed mnemonic, etc.). Replace with + explicit JoinError → `PlatformWalletFFIResult::err` mapping, or + set `panic = "abort"` in the FFI crate's release profile so + unwinding across `extern "C"` is impossible. + +- **Crypto H1** — `ExtendedPrivKey` intermediates in + `MnemonicResolverCoreSigner::sign_ecdsa` aren't `ZeroizeOnDrop` + (dashcore upstream issue). 32-byte scalar sits on the stack + briefly until naturally overwritten. Microsecond window; + fixable upstream via a `ZeroizeOnDrop` impl on dashcore's + `ExtendedPrivKey`. + +- **Crypto H2** — `MnemonicResolverCoreSigner` lifetime is + comment-managed via `usize`-stored pointer. Soundness relies on + the FFI caller honoring the doc-comment contract. Type-system + fix: `PhantomData<&'a MnemonicResolverHandle>` + borrowed + constructor. Needs `Arc` at the + boundary for `Send + 'static` future capture. Document or + implement. + +#### 🧪 Recommended additional regression tests (from auditor) + +1. Recovery-id corner case — hand-craft a fixed-seed test that + forces each recid ∈ {0, 1, 2, 3} to be the matching id, pin + byte-identical output across all four. +2. Path equality pin — build asset-lock with fake signer, recover + credit-output script_pubkey's pubkey hash, ask same signer for + `public_key(returned_path)`, hash it, assert + `Hash160(pk) == script_pubkey_hash`. +3. Cleanup parity — assert + `register_identity_with_funding_external_signer` removes the + tracked lock on success (currently fails — see H3). +4. Concurrent registrations — two `registerIdentityWithFunding` + flows on the same wallet, verify Keychain serialization + + `wallet_manager` write-lock interaction. + +--- + +### Iter 3 — SwiftData mirror + persister callback (was iter 2) + +**Goal**: make tracked asset locks visible to SwiftUI via +`@Query`. Unblocks the progress bar (iter 3) and resume picker +(iter 5). + +The FFI persister callback table at `persistence.rs:104` has +**no asset-lock callback** today. Rust's `manager.rs:85` queues +asset-lock changesets internally but the FFI bridge drops them. +Also: `persistence.rs:1994` hardcodes `unused_asset_locks: +BTreeMap::new()` on wallet load, so even out-of-band persistence +would not rehydrate on launch. + +**Steps**: + +1. **FFI: add `on_persist_asset_locks_fn`** to + `PlatformWalletPersistenceCallbacks` (`persistence.rs:64+`) + carrying `(wallet_id, upserts: *const AssetLockEntryFFI, + upserts_count, removed: *const [u8;36], removed_count)`. +2. **FFI: add `AssetLockEntryFFI`** `#[repr(C)]` mirror of + `AssetLockEntry` (`changeset.rs:680-701`). Bincode-serialize + the optional `AssetLockProof`. +3. **FFI: wire dispatcher** in `persistence.rs::store()` around + the existing per-kind blocks. +4. **FFI: extend `WalletRestoreEntryFFI`** with + `tracked_asset_locks: *const AssetLockEntryFFI` + `count`; + populate `unused_asset_locks` at `persistence.rs:1994` from + the restored rows so wallet-load rehydrates from SwiftData. +5. **SwiftData: add `PersistentAssetLock`** at + `Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift`: + + ```swift + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + @Attribute(.unique) var outPointHex: String // txid:vout + var walletId: Data + var transactionBytes: Data + var fundingTypeRaw: Int + var identityIndexRaw: Int32 + var amountDuffs: Int64 + var statusRaw: Int // 0..3 = Built/Broadcast/IS/CL + var proofBytes: Data? + var createdAt: Date + var updatedAt: Date + } + ``` + +6. **Register the model** in `DashModelContainer.modelTypes` + (`DashModelContainer.swift:32`). +7. **Hook the persister handler**: extend + `PlatformWalletPersistenceHandler` with an asset-lock case + following the existing upsert pattern (e.g. + `persistAddressBalances` at lines 88-113) — fetch by predicate, + mutate-or-insert, defer save inside changeset bracket. +8. **Add a row to `StorageExplorerView`** for + `PersistentAssetLock`, matching the pattern at + `StorageExplorerView.swift:27-78`. **SwiftData-backed**, not + FFI-backed — proves the persister round-trip works end-to-end + before later iterations rely on it. + +**Validation**: trigger an identity registration (the iter 1 +flow), watch `StorageExplorerView`. A row should appear at +`Built`, advance through `Broadcast` → `InstantSendLocked`, +**then stay** (because iter 4's cleanup fix hasn't shipped yet). +Manual screen refresh OK; no progress bar yet. + +**Known accumulation**: every successful registration leaves a +stale row at `InstantSendLocked` until iter 4 ships. Clutter in +StorageExplorer, harmless in normal user flow (the slot is +consumed via `PersistentIdentity`, so the row is unreachable +from `CreateIdentityView`). + +--- + +### Iter 3 — Stage progress bar + RegistrationCoordinator + +**Goal**: replace iter 1's generic spinner with a 5-step +stage-aware progress bar. Survive view dismissal. + +**Stage source**: SwiftData `@Query` on `PersistentAssetLock` +(from iter 2) plus a Swift-side `ObservableObject` controller +for the bookend phases. + +**Matching rule**: Swift cannot know the outpoint *before* the +FFI call returns. Match by `(walletId, identityIndex)` instead +— `TrackedAssetLock` already carries `identity_index` +(`tracked.rs:27`), and the UI enforces one in-flight registration +per `(walletId, identityIndex)` slot via `unusedIdentityIndices`. + +**5-step UI**: + +| Step | Driven by | +|---|---| +| 1. Preparing identity keys | controller `phase == .preparingKeys` | +| 2. Building asset-lock tx | `activeLock.statusRaw == 0` (Built) | +| 3. Broadcasting & waiting for instant-lock | `activeLock.statusRaw == 1` (Broadcast) | +| 4. Submitting to Platform | `activeLock.statusRaw == 2 or 3` AND controller still `.inFlight` | +| 5. Identity registered | controller `.completed` (controller-driven, **not** row deletion — iter 4 introduces the cleanup) | + +**Failure semantics**: errors set controller to +`.failed(message:)`. From iter 4 onward the tracked lock row +stays on failure only (success removes it). Until iter 4 ships, +the row stays on both success and failure — UI is unaffected +because step 5 is controller-driven. + +**Steps**: + +1. **`IdentityRegistrationController`** (per repo convention + from Swift arch review: `ObservableObject` + `@Published`, + **NOT** `@Observable` which the codebase doesn't use): + + ```swift + @MainActor + final class IdentityRegistrationController: ObservableObject { + enum Phase: Equatable { + case idle + case preparingKeys + case inFlight + case completed(identityId: Data) + case failed(String) + } + @Published var phase: Phase = .idle + + func submit(/* walletId, identityIndex, funding, signer */) async { ... } + } + ``` + +2. **`RegistrationCoordinator`** singleton, hosted on + **`PlatformWalletManager`** (per Swift arch review: AppState + is a bootstrap host, PlatformWalletManager is the per-network + operational hub and survives view dismissals). Keyed by + `(walletId, identityIndex)`, stores active controllers, + single-flights per slot. + +3. **`CreateIdentityView`** binds to + `walletManager.registrationCoordinator.startRegistration( + walletId:, identityIndex:, funding:, …)` — reuses an existing + controller for the slot, or creates a new one. + +4. **`RegistrationProgressView`** reads + `controller.phase` + `@Query` `activeLock.first?.statusRaw` + to compute `currentStep`, renders 5 step rows with + done/active/pending/failed states. `@Query` filtered by + `walletId + identityIndexRaw == identityIndex`. + +5. **"Pending registrations" row** on home / identities tab — + lists active controllers from the coordinator, so dismissed + flows remain reachable. Empty when the map is empty. + +6. **Retention**: on `.completed`, keep the entry in the + coordinator map briefly (~30s) before purging. On `.failed`, + keep indefinitely until the user manually dismisses. + +7. **Disable network toggle** while the coordinator map is + non-empty (per adversarial review — switching testnet↔mainnet + mid-flight tears down the SDK). + +**Iter-3 call site**: this iteration's call into the Swift +wrapper still uses the iter-1 signature +`registerIdentityWithFunding(amountDuffs:identityIndex:identityPubkeys:signer:)`. +Iter 4 updates the wrapper to take `funding: IdentityFunding`; +the controller's call site updates mechanically at that point. + +--- + +### Iter 4 — Rust refactor + cleanup fix + resume support ✅ **DONE** + +**Outcome**: L1/L2 merge complete; auto IS→CL fallback wired at registration layer; H3 cleanup-on-success; multi-wallet Keychain isolation; typed signer errors (`MnemonicResolverSignerError` + `ProtocolError::ExternalSignerError`); consistent Send+Sync. All cargo tests green (122 + 78 + 3436). Testnet validation confirmed asset-lock build + broadcast + signing path works end-to-end, but exposed an SPV event-routing concern — see § SPV event-routing follow-up below. + + + +**Goal**: collapse the three overlapping registration functions +into a two-layer factoring, fix the asset-lock leak, add +resume capability to the funding enum. After iter 4, the wallet- +balance path still works (re-validate iter 1's happy path) but +the function shape is what later iterations build on. + +#### Target shape + +| Layer | Function | Responsibility | +|---|---|---| +| **L1** | `register_identity_with_signer` (existing, signature updated) | Pure submit primitive. Takes `keys_map`, raw proof, raw key, signer. Builds placeholder Identity internally so callers don't repeat boilerplate. Calls `put_to_platform_and_wait_for_response`. No retry, no funding, no cleanup, no bookkeeping. | +| **L2** | `register_identity_with_funding` (renamed from `register_identity_with_funding_external_signer`) | Full orchestration. Takes `keys_map` + `IdentityFunding` + `identity_index` + signer. Pre-flight + funding dispatch + L1-submit + IS→CL fallback (re-submits via L1) + `IdentityManager` bookkeeping + `remove_asset_lock` cleanup. | +| — | `funded_register_identity` | **Deleted.** All useful behavior absorbed into L2. | + +#### Steps + +**Step 1 — Rust enum** (`types/funding.rs`): + +- Keep `IdentityFunding` (`:27-42`); add a third variant + `UseAssetLock { proof: AssetLockProof, private_key: PrivateKey }` + mirroring the retired `IdentityFundingMethod::UseAssetLock`. + No live consumer today but the variant + match arm cost ~3 + lines and future consumers (walletless paste, evo-tool + import) get to wire up without a Rust schema change. +- Delete `IdentityFundingMethod` (`:47-68`). +- Delete `TopUpFundingMethod` (`:71-86`) **only if grep confirms + no other consumers** — otherwise leave intact and migrate + top-up separately. +- Update the file header comment (`:1-13`). + +Final enum: + +```rust +pub enum IdentityFunding { + FromWalletBalance { amount_duffs: u64 }, + FromExistingAssetLock { out_point: OutPoint }, + UseAssetLock { proof: AssetLockProof, private_key: PrivateKey }, +} +``` + +**Step 2 — L1 `register_identity_with_signer`** (`registration.rs:240`): + +Change signature to take `keys_map: BTreeMap` +instead of pre-built `Identity`. Build the placeholder Identity +internally: + +```rust +pub async fn register_identity_with_signer>( + &self, + keys_map: BTreeMap, + asset_lock_proof: AssetLockProof, + asset_lock_private_key: &dashcore::PrivateKey, + signer: &S, + settings: Option, +) -> Result { + let identity = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + identity + .put_to_platform_and_wait_for_response(...) + .await +} +``` + +Pre-flight checks (`keys_map` non-empty, key 0 = MASTER+AUTH) +stay at L2, not here — L1 is a primitive that trusts its +caller. Currently called only by `funded_register_identity` +(`:348`, `:369`), which is being deleted in Step 4. After the +merge L2 becomes the new caller. + +**Step 3 — L2 `register_identity_with_funding`** (renamed from +`register_identity_with_funding_external_signer`, `:59`): + +- Rename. +- Change `funding: IdentityFundingMethod` → `funding: IdentityFunding`. +- Keep pre-flight checks (`:70-98`). +- Replace the 2-arm funding match (`:101-116`) with three arms, + **each capturing `tracked_out_point: Option`** + (currently dropped at `:105` as `_out_point` — the leak): + - `FromWalletBalance { amount_duffs }` — existing body + (`create_funded_asset_lock_proof`); keep returned + `out_point` as `Some(out_point)`. + - `UseAssetLock { proof, private_key }` — existing body, plus + `Self::out_point_from_proof(&proof)` for tracked outpoint. + - `FromExistingAssetLock { out_point }` — **new arm**, calls + `self.asset_locks.resume_asset_lock(&out_point, + Duration::from_secs(300))`; pass outpoint through as + `Some(out_point)`. +- Remove inline Identity construction (`:119-124`) — now in L1. +- Submit via L1 (`self.register_identity_with_signer(keys_map, + ...)`) instead of inline `put_to_platform_and_wait_for_response`. + Both the initial call and the IS→CL fallback retry go through + L1. +- IS→CL fallback wrapping (`:140-178`) stays. +- IdentityManager bookkeeping (`:181-220`) stays. +- **Add `remove_asset_lock` cleanup** after successful + bookkeeping (post `:220`): + ```rust + if let Some(out_point) = tracked_out_point { + self.asset_locks.remove_asset_lock(&out_point).await; + } + ``` + +Open tension: the cleanup-on-success path makes a `Registered` +variant on `AssetLockStatus` impossible (no row to hold it). +See Open Questions. + +**Step 4 — Delete `funded_register_identity`** (`:311+`). + +All behavior is now in L2: +- Funding dispatch ✅ (Step 3, with full 3-variant support) +- IS→CL fallback ✅ (Step 3 via L1) +- Cleanup ✅ (Step 3) +- IdentityManager bookkeeping (L2 always does this; the old + function deliberately skipped it). + +**Step 5 — FFI** (`identity_registration_funded_with_signer.rs`): + +Extend the existing `platform_wallet_register_identity_with_funding_signer` +entry point. Change `amount_duffs: u64` parameter to a tagged +`IdentityFundingFFI` struct. Pattern: flat `#[repr(C)]` struct +with a `kind: u8` discriminator + per-variant fields (precedent: +`identity_registration_with_signer.rs:111-127`, NOT a C union): + +```c +typedef struct IdentityFundingFFI { + uint8_t kind; // 0 / 1 / 2 + uint64_t amount_duffs; // kind == 0 + uint8_t txid[32]; // kind == 1 + uint32_t vout; // kind == 1 + const uint8_t *proof_bytes; // kind == 2 (bincode-serialized) + uintptr_t proof_len; // kind == 2 + uint8_t private_key[32]; // kind == 2 +} IdentityFundingFFI; +``` + +The FFI body dispatches on `kind`, constructs the matching +`IdentityFunding` variant, calls the new L2. + +**Step 6 — Swift wrapper** (`ManagedPlatformWallet.swift:2356`): + +Replace the current +`registerIdentityWithFunding(amountDuffs:identityIndex:identityPubkeys:signer:)` +with a funding-typed version: + +```swift +public enum IdentityFunding { + case fromWalletBalance(amountDuffs: UInt64) + case fromExistingAssetLock(outPoint: OutPoint) + case useAssetLock(proof: Data, privateKey: Data) +} + +public func registerIdentityWithFunding( + funding: IdentityFunding, + identityIndex: UInt32, + identityPubkeys: [IdentityPubkey], + signer: KeychainSigner +) async throws -> (Identifier, ManagedIdentity) +``` + +Marshals to the tagged FFI struct. Update +`CreateIdentityView.submit()` from iter 1 to use the new +signature — call site change is mechanical (`amountDuffs: X` → +`funding: .fromWalletBalance(amountDuffs: X)`). + +**Step 7 — Re-validate iter 1's happy path** + +Build, run on testnet, register an identity. Confirm the +tracked-lock cleanup is now happening (no leak). + +**Pre-existing bug, out of scope**: if `IdentityManager::add_identity` +at `:199` fails *after* successful Platform submission, the +function returns early via `?` — the identity is on Platform +but the wallet doesn't know it, AND the tracked asset lock stays +in storage. Predates our changes. Follow-up issue, not this PR. + +--- + +### Iter 5 — "Fund from unused Asset Lock" picker + crash recovery + +**Goal**: enable resuming a tracked asset lock when the +previous registration didn't complete. Validate crash recovery +end-to-end. + +**Resume picker semantics**: an "unused" lock is one at status +`InstantSendLocked` or `ChainLocked` for which **no +`PersistentIdentity` exists** at the same `(walletId, +identityIndex)`. (Not `identityIndex == nil` — that field is +always set on a tracked lock.) + +**Steps**: + +1. **Resume-picker `@Query`** on `PersistentAssetLock` filtered + by `walletId == selectedWalletId AND statusRaw >= 2 AND no + matching PersistentIdentity at (walletId, identityIndexRaw)`. + Compound query — may need a post-fetch filter for the + anti-join. + +2. **Update `CreateIdentityView`** so picking + `.unusedAssetLock` and a specific tracked lock from the list + wires through to `registrationCoordinator.startRegistration( + walletId:, identityIndex: lock.identityIndexRaw, funding: + .fromExistingAssetLock(outPoint: lock.outPointHex), …)`. + +3. **Crash-recovery validation**: trigger a registration, kill + the app between `Broadcast` and Platform submission. Re-launch. + Verify the tracked lock appears in `StorageExplorerView` / + `WalletMemoryExplorerView`. Open CreateIdentity → "Fund from + unused Asset Lock" → submit → identity registers, tracked + lock removed. + +--- + +### Iter 6 — Explorer drill-downs + +**Goal**: full explorer visibility for tracked asset locks +beyond the StorageExplorer row delivered in iter 2. + +**Steps**: + +1. **`StorageExplorerView` detail view** for + `PersistentAssetLock`: list locks with `outPointHex`, + `status`, `amountDuffs`, `identityIndexRaw`, `createdAt`, + `updatedAt`. SwiftData-backed. + +2. **`WalletMemoryExplorerView` drill-down**: expand the + existing "N asset locks" count (`:368`) into a sub-section + per wallet showing the live FFI snapshot + (`trackedAssetLocks(for: walletId)`). Follow the + `walletsSection` pattern at `:325`. FFI-backed (this view is + for *in-memory* wallet state, not SwiftData). + +--- + +### Iter 7 (optional) — Walletless paste flow + +Out of scope unless explicitly requested. Lets the user paste a +raw asset-lock proof + private key + identity pubkeys and +register an identity with no wallet derivation. Uses the +`IdentityFunding::UseAssetLock` variant added in iter 4 — the +funding plumbing exists, only the UI is missing. + +--- + +### 🚨 SPV event-routing follow-up (P0 BLOCKER for end-to-end testnet) + +**Symptom (observed twice on testnet)**: asset-lock tx broadcasts and gets mined+chain-locked on testnet (verified via explorer with 12+ confirmations), but the asset-lock manager's `wait_for_proof` poll loop never sees the tx-record `context` advance from `Broadcast` to `InstantSend(_)` or `InChainLockedBlock(_)`. **Both the IS-lock 300s wait AND iter 4's new auto CL-fallback 180s wait time out**. The Swift Transactions screen shows the tx as "Confirmed" but the tracked-asset-lock status (queried via `trackedAssetLocks(for:)`) stays at `Broadcast`. + +**Root cause hypothesis**: SPV processes wallet txs into a basic "confirmed" state but doesn't propagate IS-lock / chain-lock signatures to `bip44_account.transactions()` map's `TransactionContext` field. The poll loop at `packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:367-399` reads exactly that field and never sees the upgrade. + +**Likely failure points** (need investigation): +1. SPV not subscribed to IS-lock / chain-lock P2P topics +2. SPV receives them but skips the wallet-tx-record update (regression from rust-dashcore bumps `e29dc7a26c` or `8156ecc08d`?) +3. Wallet integration layer drops the events on the floor + +**Why this didn't surface before iter 2**: the soft-wallet asset-lock path failed *earlier* with "Cannot sign with watch-only wallet". Iter 2's signer plumbing succeeded enough to reach the `wait_for_proof` step where this issue actually lives. So the SPV event-routing bug has been latent for a while. + +**Triage path**: +1. Inspect `dash-spv` crate's IS-lock / chain-lock event handlers — confirm they call the wallet integration to update tx contexts +2. Compare against pre-rust-dashcore-bump behavior via git history +3. Add tracing logs in the SPV event-handler path; or expose a Swift-side diagnostic that surfaces the SPV subscription state +4. If event-handler is wired but masternode side is silent (testnet IS-lock latency), the CL fallback should still complete — but it didn't, so this is NOT just IS-lock silence + +**Unblocks**: end-to-end testnet validation, all subsequent iters (iter 3-7 don't directly depend on this, but the whole feature is broken without working IS/CL events). + +**Out of scope for the iter 4 PR** — this is a separate investigation into the SPV layer. Track as a P0 follow-up. + +## Open questions + +- **Default funding amount**: 100,000 duffs (0.001 DASH)? +- **Asset-lock minimum constant**: name + value, verify < + testnet faucet typical payout (per adversarial review W8). +- **Key count**: stick with `defaultKeyCount = 3` (1 master + 2 + high), or expose a picker? +- **`AssetLockStatus` extension** (iter 4 vs later): adding a + `RegisteringOnPlatform` variant Rust-side would make step 4 + of the progress bar crisp (Rust signals when it moves from + "waiting for IS-lock" to "submitting to Platform"). Without + it, step 4 fires the instant IS-lock arrives, which may show + "Submitting" prematurely if Rust internally retries. A + `Registered` variant is **not possible** because the row is + removed on cleanup. Defer the `RegisteringOnPlatform` + decision until iter 4. + +## Out of scope (explicitly) + +- Mnemonic creation / import flow (already exists). +- SPV / BLAST sync changes (already exists). +- `top_up_identity_with_funding` migration to `IdentityFunding` + (separate cleanup — only delete `TopUpFundingMethod` in iter 4 + if grep confirms no live consumers). +- Manual asset-lock proof construction beyond iter 7's optional + paste UI. + +## Architectural constraints (must follow) + +From `packages/swift-sdk/CLAUDE.md`: + +- Swift SDK does three things only: persist data, load data, + bridge. +- No mnemonic / seed / derivation path construction in Swift. +- No iteration / gap-limit walks / policy loops in Swift. +- Decisions live Rust-side. If Rust doesn't expose a single + call for what we need, add the helper in `platform-wallet` + first. + +The one allowed exception is iOS Keychain writes (Rust derives +the bytes, Swift persists). `prePersistIdentityKeysForRegistration` +is the precedent we follow. + +From the Swift architectural review: + +- Use `ObservableObject` + `@Published`, **not** `@Observable` + (zero `@Observable` usage in this codebase). +- Host coordinators on `PlatformWalletManager`, **not** + `AppState`. Operations are per-wallet hence per-network; + `PlatformWalletManager` is the natural per-network hub and + survives view dismissals. +- Register every new SwiftData `@Model` in + `DashModelContainer.modelTypes`. Add `#Index` for + query-heavy scalar fields. +- Use `walletId: Data` (denormalized scalar) for filtering + predicates rather than relationships — the existing + `PersistentTxo` pattern, more reliable than the + `PersistentIdentity` relationship-based approach. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index fdb1b95595f..a664ed4e8a6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -36,6 +36,22 @@ struct CreateIdentityView: View { /// docstring; kept here so the conversion logic stays local. private static let creditsPerDash: UInt64 = 100_000_000_000 + /// Duffs per DASH (1e8) — Core-side scale, used by the Core-funded + /// identity path. + private static let duffsPerDash: UInt64 = 100_000_000 + + /// Protocol minimum asset-lock funding for an identity create. + /// Mirrors `required_asset_lock_duff_balance_for_processing_start_for_identity_create` + /// in `rs-platform-version` (200,000 duffs / 0.002 DASH, stable + /// across v1/v2/v3). Submitting below this gets rejected by + /// Platform's validation. + private static let minIdentityFundingDuffs: UInt64 = 200_000 + + /// Default funding amount pre-filled into the field for the Core + /// path. Equal to the protocol minimum; users dial up if they want + /// more headroom on the resulting identity's initial credit balance. + private static let defaultCoreFundingDuffs: UInt64 = 200_000 + /// All locally-persisted wallets. Drives the Source Wallet /// picker along with the synthetic "no wallet" sentinel. @Query(sort: \PersistentWallet.createdAt) private var wallets: [PersistentWallet] @@ -218,9 +234,8 @@ struct CreateIdentityView: View { } } - /// Amount (in DASH) to fund the new identity with. Only shown - /// once the user has picked a funding source the current flow - /// can actually spend from (Platform Payment account). + /// Amount (in DASH) to fund the new identity with. Shown for + /// Platform Payment and Core / CoinJoin funding sources. @ViewBuilder private var amountSection: some View { if let account = selectedPlatformAccount { @@ -242,6 +257,29 @@ struct CreateIdentityView: View { ) Text("Available: \(available). The new identity will start with this amount funded from the selected addresses.") } + } else if let account = selectedCoreAccount { + Section { + HStack { + TextField("Amount", text: $amountDash) + .keyboardType(.decimalPad) + .textFieldStyle(.roundedBorder) + .disabled(isCreating) + Text("DASH") + .foregroundColor(.secondary) + } + } header: { + Text("Amount") + } footer: { + let available = Self.formatDash( + raw: coreAccountBalanceDuffs(account), + divisor: Double(Self.duffsPerDash) + ) + let minimum = Self.formatDash( + raw: Self.minIdentityFundingDuffs, + divisor: Double(Self.duffsPerDash) + ) + Text("Available: \(available). Minimum: \(minimum). Rust builds an asset-lock transaction from your Core UTXOs and the locked funds become the new identity's initial credit balance.") + } } } @@ -378,8 +416,13 @@ struct CreateIdentityView: View { guard let credits = parsedAmountCredits else { return false } return credits > 0 && credits <= accountBalance(account) } - // Non-Platform-payment wallet-backed paths are still - // stubbed — don't light the button until they're wired. + if let account = selectedCoreAccount { + guard let duffs = parsedAmountDuffs else { return false } + let available = coreAccountBalanceDuffs(account) + return duffs >= Self.minIdentityFundingDuffs && duffs <= available + } + // Remaining wallet-backed paths (unused asset lock, + // future variants) are stubbed — submit stays disabled. return false default: return false @@ -388,15 +431,15 @@ struct CreateIdentityView: View { // MARK: - Submit - /// Runs the Platform-payment-funded identity registration path. - /// Other funding branches are intentionally stubbed — the button - /// stays disabled for them via `canSubmit`. + /// Dispatches identity registration to the correct funding path. + /// Platform-Payment funding uses `registerIdentityFromAddresses`; + /// Core / CoinJoin funding uses `registerIdentityWithFunding` + /// (asset-lock proof built Rust-side from wallet UTXOs). Other + /// funding branches (unused asset-lock, walletless) stay disabled + /// via `canSubmit` until later iterations. private func submit() { guard - let account = selectedPlatformAccount, let identityIndex = identityIndex, - let targetCredits = parsedAmountCredits, - targetCredits > 0, case .wallet(let walletId) = walletSelection else { submitError = .init(message: "Selection is incomplete.") @@ -451,6 +494,48 @@ struct CreateIdentityView: View { return } + let network: Network = platformState.currentNetwork + + // Dispatch by funding source. + if let account = selectedPlatformAccount { + submitPlatformPayment( + account: account, + walletId: walletId, + identityIndex: identityIndex, + identityPubkeys: identityPubkeys, + signer: signer, + managedWallet: managedWallet, + network: network + ) + } else if selectedCoreAccount != nil { + submitCoreFunded( + walletId: walletId, + identityIndex: identityIndex, + identityPubkeys: identityPubkeys, + signer: signer, + managedWallet: managedWallet, + network: network + ) + } else { + submitError = .init(message: "Selected funding source is not yet supported.") + } + } + + /// Platform-Payment funded registration. Spends credits from + /// `PersistentPlatformAddress` rows on the selected account. + private func submitPlatformPayment( + account: PersistentAccount, + walletId: Data, + identityIndex: UInt32, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + signer: KeychainSigner, + managedWallet: ManagedPlatformWallet, + network: Network + ) { + guard let targetCredits = parsedAmountCredits, targetCredits > 0 else { + submitError = .init(message: "Enter a positive amount.") + return + } // Greedy-select addresses to cover `targetCredits`. The // last address's credits field is capped to the remaining // amount so the total spent matches exactly. @@ -465,8 +550,6 @@ struct CreateIdentityView: View { isCreating = true - let network: Network = platformState.currentNetwork - Task { do { let created = try await managedWallet.registerIdentityFromAddresses( @@ -480,7 +563,7 @@ struct CreateIdentityView: View { try await MainActor.run { try persistCreatedIdentity( - created, + identityId: created.identityId, network: network ) markIdentitySlotUsed( @@ -502,6 +585,59 @@ struct CreateIdentityView: View { } } + /// Core / CoinJoin funded registration. Rust builds an asset-lock + /// transaction from BIP44 account #0's UTXOs (mempool `account_index = 0` + /// in `create_funded_asset_lock_proof`), broadcasts it, waits for + /// the instant-send lock, and submits the IdentityCreate state + /// transition. Iter 1: single in-flight spinner, no stage UI. + private func submitCoreFunded( + walletId: Data, + identityIndex: UInt32, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + signer: KeychainSigner, + managedWallet: ManagedPlatformWallet, + network: Network + ) { + guard let amountDuffs = parsedAmountDuffs, amountDuffs > 0 else { + submitError = .init(message: "Enter a positive amount.") + return + } + + isCreating = true + + Task { + do { + let (identityId, _) = try await managedWallet.registerIdentityWithFunding( + amountDuffs: amountDuffs, + identityIndex: identityIndex, + identityPubkeys: identityPubkeys, + signer: signer + ) + + try await MainActor.run { + try persistCreatedIdentity( + identityId: identityId, + network: network + ) + markIdentitySlotUsed( + walletId: walletId, + identityIndex: identityIndex + ) + try modelContext.save() + self.createdIdentityId = identityId + self.isCreating = false + } + } catch { + await MainActor.run { + self.submitError = .init( + message: error.localizedDescription + ) + self.isCreating = false + } + } + } + } + /// Convert the selected Platform Payment account's /// `PersistentPlatformAddress` rows into the flat FFI input list, /// stopping once we have enough credits to cover `targetCredits`. @@ -570,20 +706,19 @@ struct CreateIdentityView: View { /// variants that flow through the callback as namespaced /// `"derived:"` identifiers — no Keychain write needed. private func persistCreatedIdentity( - _ created: ManagedPlatformWallet.CreatedIdentity, + identityId: Data, network: Network ) throws { - let identityId = created.identityId let descriptor = FetchDescriptor( predicate: #Predicate { $0.identityId == identityId } ) guard let row = try modelContext.fetch(descriptor).first else { // Row hasn't landed yet — shouldn't happen in - // production (Rust emits the changeset before - // `registerIdentityFromAddresses` returns), but if the - // persister callback isn't wired the UI-side fields - // stay unset until a subsequent refresh. Log + fall - // through so the registration doesn't fail. + // production (Rust emits the changeset before the + // registration FFI returns), but if the persister + // callback isn't wired the UI-side fields stay unset + // until a subsequent refresh. Log + fall through so the + // registration doesn't fail. print("⚠️ persistCreatedIdentity: no PersistentIdentity row for \(identityId.toHexString()) — persister callback likely not wired") return } @@ -626,28 +761,76 @@ struct CreateIdentityView: View { return account } + /// The currently-selected Core / CoinJoin account, if any. Used + /// for the Core-funded identity-creation path (Standard BIP44/BIP32 + /// or CoinJoin). The Rust function `create_funded_asset_lock_proof` + /// reads UTXOs from BIP44 account #0 by convention; for a fresh + /// wallet this matches what the user has. + private var selectedCoreAccount: PersistentAccount? { + guard + case .account(let persistentId) = fundingSelection, + let account = allAccounts.first(where: { + $0.persistentModelID == persistentId + }), + account.accountType == 0 || account.accountType == 1 + else { + return nil + } + return account + } + /// Raw credit balance across all addresses in a PlatformPayment /// account. private func accountBalance(_ account: PersistentAccount) -> UInt64 { account.platformAddresses.reduce(0) { $0 + $1.balance } } - /// Default amount string (DASH) for the amount field — the full - /// balance of the selected Platform Payment account. + /// Spendable balance (duffs) for a Core / CoinJoin account. + /// Reads live FFI-backed balance from the platform-wallet manager + /// (the SwiftData per-account `balanceConfirmed` scalar isn't + /// maintained by the persister — `AccountDetailView.balanceCard` + /// uses the same live source). Returns 0 if no match. + private func coreAccountBalanceDuffs(_ account: PersistentAccount) -> UInt64 { + let balances = walletManager.accountBalances(for: account.wallet.walletId) + guard let match = balances.first(where: { b in + UInt32(b.typeTag) == account.accountType + && b.standardTag == account.standardTag + && b.index == account.accountIndex + }) else { + return 0 + } + return match.confirmed + match.unconfirmed + } + + /// Default amount string (DASH) for the amount field. + /// Platform Payment → full account balance (one-tap happy path). + /// Core / CoinJoin → 0.001 DASH (the asset-lock minimum-with- + /// headroom default); user dials up if they want a larger + /// initial credit balance. private func defaultAmountString(for funding: FundingSelection?) -> String { guard case .account(let persistentId) = funding, let account = allAccounts.first(where: { $0.persistentModelID == persistentId - }), - account.accountType == 14 + }) else { return "" } - let balance = accountBalance(account) - if balance == 0 { return "" } - let dash = Double(balance) / Double(Self.creditsPerDash) - return String(format: "%g", dash) + switch account.accountType { + case 14: + let balance = accountBalance(account) + if balance == 0 { return "" } + let dash = Double(balance) / Double(Self.creditsPerDash) + return String(format: "%g", dash) + case 0, 1: + let available = coreAccountBalanceDuffs(account) + let defaultDuffs = min(available, Self.defaultCoreFundingDuffs) + if defaultDuffs == 0 { return "" } + let dash = Double(defaultDuffs) / Double(Self.duffsPerDash) + return String(format: "%g", dash) + default: + return "" + } } /// Parse the amount text back into credits. Returns `nil` on @@ -663,6 +846,18 @@ struct CreateIdentityView: View { return UInt64(credits) } + /// Parse the amount text into Core duffs (1e8 per DASH). Used for + /// the Core-funded identity path. + private var parsedAmountDuffs: UInt64? { + let trimmed = amountDash.trimmingCharacters(in: .whitespaces) + guard let dash = Double(trimmed), dash.isFinite, dash > 0 else { + return nil + } + let duffs = (dash * Double(Self.duffsPerDash)).rounded() + guard duffs >= 1, duffs <= Double(UInt64.max) else { return nil } + return UInt64(duffs) + } + // MARK: - Helpers private func walletLabel(for wallet: PersistentWallet) -> String { @@ -687,11 +882,19 @@ struct CreateIdentityView: View { /// child rows. Ordering matches `AccountListView`: BIP44 → /// PlatformPayment → BIP32 → CoinJoin. private func accountOptions(for walletId: Data) -> [FundingAccountOption] { - allAccounts + // Live balances from the Rust side. The persister doesn't + // maintain `PersistentAccount.balanceConfirmed`, so we read + // from the platform-wallet manager (same source as + // `AccountDetailView.balanceCard`). + let liveBalances = walletManager.accountBalances(for: walletId) + return allAccounts .filter { account in guard account.wallet.walletId == walletId else { return false } guard CreateIdentityView.isFundingAccount(account) else { return false } - return CreateIdentityView.accountBalanceSummary(account).hasBalance + return CreateIdentityView.hasBalance( + account: account, + liveBalances: liveBalances + ) } .sorted { lhs, rhs in let lhsKey = CreateIdentityView.sortKey(for: lhs) @@ -699,7 +902,10 @@ struct CreateIdentityView: View { return lhsKey < rhsKey } .map { account in - let (_, balanceText) = Self.accountBalanceSummary(account) + let balanceText = Self.balanceText( + for: account, + liveBalances: liveBalances + ) return FundingAccountOption( persistentId: account.persistentModelID, label: Self.fundingLabel(for: account), @@ -708,6 +914,48 @@ struct CreateIdentityView: View { } } + /// Whether an account has any spendable balance for the picker + /// filter. Same data sources as `balanceText` (live FFI for Core / + /// CoinJoin, SwiftData for PlatformPayment). + private static func hasBalance( + account: PersistentAccount, + liveBalances: [PlatformWalletManager.AccountBalance] + ) -> Bool { + switch account.accountType { + case 14: + return account.platformAddresses.contains { $0.balance > 0 } + default: + let match = liveBalances.first { b in + UInt32(b.typeTag) == account.accountType + && b.standardTag == account.standardTag + && b.index == account.accountIndex + } + return (match?.confirmed ?? 0) + (match?.unconfirmed ?? 0) > 0 + } + } + + /// Picker-row balance text for an account. Core / CoinJoin reads + /// from `liveBalances` (FFI snapshot); PlatformPayment sums + /// `platformAddresses[].balance` from SwiftData. + private static func balanceText( + for account: PersistentAccount, + liveBalances: [PlatformWalletManager.AccountBalance] + ) -> String { + switch account.accountType { + case 14: + let credits = account.platformAddresses.reduce(0) { $0 + $1.balance } + return credits > 0 ? formatDash(raw: credits, divisor: 100_000_000_000.0) : "empty" + default: + let match = liveBalances.first { b in + UInt32(b.typeTag) == account.accountType + && b.standardTag == account.standardTag + && b.index == account.accountIndex + } + let duffs = (match?.confirmed ?? 0) + (match?.unconfirmed ?? 0) + return duffs > 0 ? formatDash(raw: duffs, divisor: 100_000_000.0) : "empty" + } + } + /// Unused identity-registration key indices on the wallet's /// Identity Registration account (FFI type tag 2). Each /// `PersistentCoreAddress` under that account represents one @@ -758,31 +1006,7 @@ struct CreateIdentityView: View { } } - /// Formatted balance for the picker row and the disabled flag. - /// Core / CoinJoin use the SPV-maintained - /// `balanceConfirmed + balanceUnconfirmed` duffs (1e8/DASH); - /// PlatformPayment sums the BLAST-synced credit balances across - /// its addresses (1e11/DASH). - private static func accountBalanceSummary( - _ account: PersistentAccount - ) -> (hasBalance: Bool, balanceText: String) { - switch account.accountType { - case 14: - let credits = account.platformAddresses.reduce(0) { $0 + $1.balance } - return ( - credits > 0, - credits > 0 ? formatDash(raw: credits, divisor: 100_000_000_000.0) : "empty" - ) - default: - let duffs = account.balanceConfirmed + account.balanceUnconfirmed - return ( - duffs > 0, - duffs > 0 ? formatDash(raw: duffs, divisor: 100_000_000.0) : "empty" - ) - } - } - - /// `"0.01 DASH"` — stripped of trailing zeros, uses up to 8 decimals. +/// `"0.01 DASH"` — stripped of trailing zeros, uses up to 8 decimals. private static func formatDash(raw: UInt64, divisor: Double) -> String { let dash = Double(raw) / divisor let fmt = NumberFormatter() From 885a1be38354eb13c07eb541bd8c4905bd0a5821 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 03:23:52 +0700 Subject: [PATCH 06/54] fix(platform-wallet-ffi): always enable masternode sync for SPV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AssetLockManager::wait_for_proof` resolves an asset-lock proof by reading `CLSig` / `ISLock` P2P messages through `ChainLockManager` + `InstantSendManager`. Both managers are only constructed by `dash-spv` when `ClientConfig::enable_masternodes == true` (see `dash-spv/src/client/lifecycle.rs`). With the flag off, the SPV client connects to masternode peers and receives the wire messages, but no manager is subscribed to them, so `MessageDispatcher` drops the bytes. Result: no IS-lock / chain-lock events ever reach our `LockNotifyHandler`, `wait_for_proof` sleeps the full 300 s deadline, and identity registration fails with `FinalityTimeout`. SwiftExampleApp was conflating "SDK in trusted mode" with "no masternode sync needed", so `masternodeSyncEnabled = !trusted_mode` silently disabled the IS/CL P2P subscription whenever the app used the trusted SDK path. The two concerns are independent — trusted mode is about who validates LLMQ quorum signatures, not about whether dash-spv listens for them. Asset-lock-funded identity registration is a published feature of the platform-wallet crate; the IS/CL subscription is a non-optional dependency. Encode that contract in the FFI by removing the `masternode_sync_enabled` knob entirely and hardcoding `config.enable_masternodes = true`. Callers that only need trusted-mode Platform queries (no asset locks) are unaffected aside from a slightly larger SPV footprint. - packages/rs-platform-wallet-ffi/src/spv.rs: Drop `masternode_sync_enabled` parameter from `platform_wallet_manager_spv_start`; hardcode `config.enable_masternodes = true` with a comment pointing at the upstream contract. - packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift: Drop `masternodeSyncEnabled` from `PlatformSpvStartConfig` and from the `platform_wallet_manager_spv_start` call. - packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift: Drop the call-site `masternodeSyncEnabled:` argument. The in-app `@State` flag still drives UI display gating; only the SPV-config propagation is removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet-ffi/src/spv.rs | 19 +++++++++++++++++-- .../PlatformWalletManagerSPV.swift | 14 ++++++++------ .../Core/Views/CoreContentView.swift | 3 +-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index b68421854a4..04cc61b2b87 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -188,6 +188,15 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_tip_unix_seconds( } /// Start SPV sync in the background. +/// +/// `enable_masternodes` is NOT a caller knob: platform-wallet always +/// needs `ChainLockManager` + `InstantSendManager` running so +/// asset-lock proofs can resolve via the `CLSig` / `ISLock` P2P +/// messages. Disabling masternode sync silently breaks +/// `AssetLockManager::wait_for_proof`, which is a published feature. +/// Hardcoded to `true` here; if a future caller has a real reason to +/// run SPV without masternode sync, it can construct the wallet +/// manager without the asset-lock path instead. #[no_mangle] #[allow(clippy::field_reassign_with_default)] pub unsafe extern "C" fn platform_wallet_manager_spv_start( @@ -199,7 +208,6 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start( peer_count: usize, restrict_to_configured_peers: bool, start_from_height: u32, - masternode_sync_enabled: bool, ) -> PlatformWalletFFIResult { check_ptr!(data_dir); let data_dir_str = unwrap_result_or_return!(CStr::from_ptr(data_dir).to_str()).to_string(); @@ -234,7 +242,14 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start( if start_from_height > 0 { config.start_from_height = Some(start_from_height); } - config.enable_masternodes = masternode_sync_enabled; + // Asset-lock proof acquisition (`AssetLockManager::wait_for_proof`) + // depends on `CLSig` / `ISLock` P2P messages reaching the wallet, + // which only happens when `ChainLockManager` + `InstantSendManager` + // are spawned (see dash-spv/src/client/lifecycle.rs). Hardcode + // here so trusted-SDK callers (who'd otherwise disable masternode + // sync) don't silently break asset-lock-funded identity + // registration. + config.enable_masternodes = true; config.restrict_to_configured_peers = restrict_to_configured_peers; for p in &peer_list { if let Ok(addr) = p.parse() { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index 832910c9423..88f17da998a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -99,6 +99,12 @@ public struct PlatformSpvSyncProgress: Sendable, Equatable { } /// Config for starting the SPV sync. +/// +/// Masternode sync (and the IS/CL P2P subscriptions that come with it) +/// is always enabled — `AssetLockManager::wait_for_proof` requires it +/// to receive InstantSend and ChainLock signatures from peers, so +/// exposing a toggle here would silently break asset-lock-funded +/// identity registration in trusted-SDK setups. public struct PlatformSpvStartConfig { public var dataDir: String public var network: Network @@ -106,7 +112,6 @@ public struct PlatformSpvStartConfig { public var peers: [String] public var restrictToConfiguredPeers: Bool public var startFromHeight: UInt32 - public var masternodeSyncEnabled: Bool public init( dataDir: String, @@ -114,8 +119,7 @@ public struct PlatformSpvStartConfig { userAgent: String? = nil, peers: [String] = [], restrictToConfiguredPeers: Bool = false, - startFromHeight: UInt32 = 0, - masternodeSyncEnabled: Bool = true + startFromHeight: UInt32 = 0 ) { self.dataDir = dataDir self.network = network @@ -123,7 +127,6 @@ public struct PlatformSpvStartConfig { self.peers = peers self.restrictToConfiguredPeers = restrictToConfiguredPeers self.startFromHeight = startFromHeight - self.masternodeSyncEnabled = masternodeSyncEnabled } } @@ -203,8 +206,7 @@ extension PlatformWalletManager { peersPtr, UInt(peerCStrings.count), config.restrictToConfiguredPeers, - config.startFromHeight, - config.masternodeSyncEnabled + config.startFromHeight ).check() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index cf3044aaec5..258cc693c95 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -572,8 +572,7 @@ var body: some View { dataDir: dataDirURL.path, network: platformState.currentNetwork, peers: peers, - restrictToConfiguredPeers: restrictToConfiguredPeers, - masternodeSyncEnabled: masternodesEnabled + restrictToConfiguredPeers: restrictToConfiguredPeers ) try walletManager.startSpv(config: config) } catch { From 4184a42525fa4af6a35402442f287eece6a619ef Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 03:24:06 +0700 Subject: [PATCH 07/54] chore: bump rust-dashcore to 5297d61a for chainlock wallet handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up dashpay/rust-dashcore#756 which adds chainlock-driven transaction finalization in the wallet layer. Previously, `WalletInterface` had no `process_chain_lock` method and `dash-spv`'s `SyncEvent::ChainLockReceived` was emitted but never consumed, so wallet records were stuck at `TransactionContext:: InBlock(_)` forever even when the network produced a chainlock for the containing block. The new pin promotes records `InBlock → InChainLockedBlock` on chainlock arrival and emits a new `WalletEvent::TransactionsChainlocked` variant carrying the chainlock proof and per-account net-new finalized txids. For our `wait_for_proof` poll loop this means the chainlock branch (`record.context.is_chain_locked()`) actually flips when peers deliver the chainlock — the iter-4 IS→CL fallback path now resolves correctly instead of timing out at the secondary 180 s deadline. The new `WalletEvent` variant forces match-arm coverage in two sites: - packages/rs-platform-wallet/src/changeset/core_bridge.rs `build_core_changeset` returns `CoreChangeSet::default()` for the new variant. The wallet has already mutated the in-memory record by the time the event fires (upstream is "mutate-then- emit"), and the poll loop reads `record.context.is_chain_locked()` directly, so no additional persister projection is needed today. A future enhancement could persist `WalletMetadata:: last_applied_chain_lock` for crash recovery, but that's out of scope here. - packages/rs-platform-wallet/src/wallet/core/balance_handler.rs `BalanceUpdateHandler::on_wallet_event` returns early for the new variant. Chainlocks promote finality (`InBlock → InChainLockedBlock`) without changing UTXO state, so there's no balance update to deliver. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 44 +++++++++---------- Cargo.toml | 18 ++++---- .../src/changeset/core_bridge.rs | 12 +++++ .../src/wallet/core/balance_handler.rs | 4 ++ 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec7d119042d..fee4ce3b76a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1138,7 +1138,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "bincode", "bincode_derive", @@ -1576,7 +1576,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "dash-network", ] @@ -1653,7 +1653,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "async-trait", "chrono", @@ -1681,7 +1681,7 @@ dependencies = [ [[package]] name = "dash-spv-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "cbindgen 0.29.2", "clap", @@ -1700,7 +1700,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "anyhow", "base64-compat", @@ -1726,12 +1726,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "dashcore-rpc-json", "hex", @@ -1744,7 +1744,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "bincode", "dashcore", @@ -1759,7 +1759,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "bincode", "dashcore-private", @@ -2294,7 +2294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3582,7 +3582,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3808,7 +3808,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "aes", "async-trait", @@ -3836,7 +3836,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "cbindgen 0.29.2", "dash-network", @@ -3852,7 +3852,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53130869e5b9343ae59016323e5e5269e717a8fd#53130869e5b9343ae59016323e5e5269e717a8fd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5297d61ac13b4bdfc85aef683e3c46e0597e6741#5297d61ac13b4bdfc85aef683e3c46e0597e6741" dependencies = [ "async-trait", "bincode", @@ -4328,7 +4328,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5337,7 +5337,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6051,7 +6051,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6064,7 +6064,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6123,7 +6123,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6946,7 +6946,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8350,7 +8350,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 078306a8b88..08eb019ccb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,15 +49,15 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "53130869e5b9343ae59016323e5e5269e717a8fd" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5297d61ac13b4bdfc85aef683e3c46e0597e6741" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index b2d9761ac2b..0de77835ef5 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -195,6 +195,18 @@ async fn build_core_changeset( synced_height: Some(*height), ..CoreChangeSet::default() }, + WalletEvent::TransactionsChainlocked { .. } => { + // The wallet has already promoted the matching records from + // `InBlock` to `InChainLockedBlock` by the time this event + // fires (upstream `WalletManager::process_chain_lock` mutates + // the in-memory map before emitting). Our poll loop reads + // record.context.is_chain_locked() directly, so no + // additional CoreChangeSet projection is needed here today; + // a future enhancement could persist the + // `WalletMetadata::last_applied_chain_lock` for crash + // recovery, but it's out of scope for the current PR. + CoreChangeSet::default() + } } } diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index fdf9120add2..d6974721275 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -53,6 +53,10 @@ impl EventHandler for BalanceUpdateHandler { } => (wallet_id, balance), // No balance on SyncHeightAdvanced — checkpoint advance only. WalletEvent::SyncHeightAdvanced { .. } => return, + // No balance on TransactionsChainlocked — chainlocks only + // promote finality (`InBlock` → `InChainLockedBlock`), + // they don't change UTXO state or balances. + WalletEvent::TransactionsChainlocked { .. } => return, }; // try_read on the wallets map (NOT the wallet_manager From 3d16a31a8068a3347a244f30ca38c79a5edd97c1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 03:24:17 +0700 Subject: [PATCH 08/54] fix(SwiftExampleApp): bump identity funding floor to v1 minimum for 3 keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform rejected identity-create transitions whose asset-lock output funded the protocol-v0 floor of 200,000 duffs, because v1's `IdentityCreateTransition::calculate_min_required_fee_v1` adds the per-key creation cost on top of the asset-lock base. With our `defaultKeyCount = 3` (master + high + transfer) the required floor is: identity_create_base_cost 2_000_000 credits + asset_lock_base × CREDITS_PER_DUFF (200_000 * 1000) 200_000_000 + identity_key_in_creation_cost × 3 (6_500_000 * 3) 19_500_000 = 221_500_000 credits / 1000 = 221_500 duffs Exactly matches the testnet rejection: "needs 221500000 credits to start processing". Bump `minIdentityFundingDuffs` to 221_500 and `defaultCoreFundingDuffs` to 250_000 (12.5% headroom so the new identity has a non-zero initial credit balance after the processing fee is deducted). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/CreateIdentityView.swift | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index a664ed4e8a6..eabcc127870 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -41,16 +41,24 @@ struct CreateIdentityView: View { private static let duffsPerDash: UInt64 = 100_000_000 /// Protocol minimum asset-lock funding for an identity create. - /// Mirrors `required_asset_lock_duff_balance_for_processing_start_for_identity_create` - /// in `rs-platform-version` (200,000 duffs / 0.002 DASH, stable - /// across v1/v2/v3). Submitting below this gets rejected by - /// Platform's validation. - private static let minIdentityFundingDuffs: UInt64 = 200_000 + /// Mirrors `IdentityCreateTransition::calculate_min_required_fee_v1` + /// in `rs-platform-version`: + /// identity_create_base_cost (2_000_000 credits) + /// + asset_lock_base (200_000 duffs * 1000 credits/duff = 200_000_000) + /// + identity_key_in_creation_cost (6_500_000) * defaultKeyCount (3) + /// = 221_500_000 credits / 1000 = 221_500 duffs (0.002215 DASH). + /// The v0 floor was 200_000 duffs but with key_in_creation_cost + /// dynamic at v1, the per-key surcharge has to be added. Submitting + /// below this gets rejected by Platform with + /// `IdentityAssetLockTransactionOutPointNotEnoughBalance`. Keep this + /// in sync if `defaultKeyCount` changes. + private static let minIdentityFundingDuffs: UInt64 = 221_500 /// Default funding amount pre-filled into the field for the Core - /// path. Equal to the protocol minimum; users dial up if they want - /// more headroom on the resulting identity's initial credit balance. - private static let defaultCoreFundingDuffs: UInt64 = 200_000 + /// path. Sits 12.5% above the protocol minimum to give headroom + /// for the resulting identity's initial credit balance after the + /// processing fee is deducted. + private static let defaultCoreFundingDuffs: UInt64 = 250_000 /// All locally-persisted wallets. Drives the Source Wallet /// picker along with the synthetic "no wallet" sentinel. From 34d702d339da9c0f02f45f50e06715ea6f2f911a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 03:25:39 +0700 Subject: [PATCH 09/54] docs(swift-sdk): mark SPV event-routing follow-up resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end Core-funded identity registration validated on testnet. The 70-line investigation history collapses to a 3-bullet resolution note pointing at the commit SHAs that landed the fix: - 885a1be3 — masternode sync hardcoded for SPV - 4184a425 — rust-dashcore bump (#756 chainlock handling) - 3d16a31a — funding floor bump to v1 minimum Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index 15ea8350ad8..ecc1d30d8d8 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -986,28 +986,13 @@ funding plumbing exists, only the UI is missing. --- -### 🚨 SPV event-routing follow-up (P0 BLOCKER for end-to-end testnet) +### SPV event-routing follow-up — RESOLVED (2026-05-13) -**Symptom (observed twice on testnet)**: asset-lock tx broadcasts and gets mined+chain-locked on testnet (verified via explorer with 12+ confirmations), but the asset-lock manager's `wait_for_proof` poll loop never sees the tx-record `context` advance from `Broadcast` to `InstantSend(_)` or `InChainLockedBlock(_)`. **Both the IS-lock 300s wait AND iter 4's new auto CL-fallback 180s wait time out**. The Swift Transactions screen shows the tx as "Confirmed" but the tracked-asset-lock status (queried via `trackedAssetLocks(for:)`) stays at `Broadcast`. +End-to-end Core-funded identity registration validated on testnet. Three causes, all landed: -**Root cause hypothesis**: SPV processes wallet txs into a basic "confirmed" state but doesn't propagate IS-lock / chain-lock signatures to `bip44_account.transactions()` map's `TransactionContext` field. The poll loop at `packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:367-399` reads exactly that field and never sees the upgrade. - -**Likely failure points** (need investigation): -1. SPV not subscribed to IS-lock / chain-lock P2P topics -2. SPV receives them but skips the wallet-tx-record update (regression from rust-dashcore bumps `e29dc7a26c` or `8156ecc08d`?) -3. Wallet integration layer drops the events on the floor - -**Why this didn't surface before iter 2**: the soft-wallet asset-lock path failed *earlier* with "Cannot sign with watch-only wallet". Iter 2's signer plumbing succeeded enough to reach the `wait_for_proof` step where this issue actually lives. So the SPV event-routing bug has been latent for a while. - -**Triage path**: -1. Inspect `dash-spv` crate's IS-lock / chain-lock event handlers — confirm they call the wallet integration to update tx contexts -2. Compare against pre-rust-dashcore-bump behavior via git history -3. Add tracing logs in the SPV event-handler path; or expose a Swift-side diagnostic that surfaces the SPV subscription state -4. If event-handler is wired but masternode side is silent (testnet IS-lock latency), the CL fallback should still complete — but it didn't, so this is NOT just IS-lock silence - -**Unblocks**: end-to-end testnet validation, all subsequent iters (iter 3-7 don't directly depend on this, but the whole feature is broken without working IS/CL events). - -**Out of scope for the iter 4 PR** — this is a separate investigation into the SPV layer. Track as a P0 follow-up. +- **Root cause**: in trusted-SDK mode the app set `masternodeSyncEnabled=false`, which disabled `dash-spv`'s `ChainLockManager` + `InstantSendManager`. The SPV client connected to masternode peers and received `CLSig`/`ISLock` P2P messages, but with no manager subscribed, `MessageDispatcher` dropped them — `LockNotifyHandler` never saw a single IS/CL event, `wait_for_proof` slept the full 300 s. Fix: hardcode `enable_masternodes = true` in `platform_wallet_manager_spv_start`; drop the FFI knob. Commit `885a1be3`. +- **Wallet record promotion**: upstream `WalletInterface` had no `process_chain_lock` until [rust-dashcore#756](https://github.com/dashpay/rust-dashcore/pull/756) merged, so records were stuck at `TransactionContext::InBlock(_)` after a chainlock. Bumped pin from `53130869` → `5297d61a` and added match arms for the new `WalletEvent::TransactionsChainlocked` variant in `core_bridge` + `balance_handler`. Commit `4184a425`. +- **Platform funding floor**: the v0 `200_000` duff minimum doesn't cover v1's per-key creation cost. With `defaultKeyCount = 3` the real floor is `221_500` duffs (`identity_create_base_cost + asset_lock_base * CREDITS_PER_DUFF + identity_key_in_creation_cost * 3`). Bumped `minIdentityFundingDuffs` to `221_500` and `defaultCoreFundingDuffs` to `250_000`. Commit `3d16a31a`. ## Open questions From e091e0c105c982ce1b8f39727b757fa850f038d8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 04:00:05 +0700 Subject: [PATCH 10/54] feat(swift-sdk): persist tracked asset locks via SwiftData mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for iter 3's stage-aware registration progress bar and iter 5's resume picker: tracked asset locks now round-trip through SwiftData via a new FFI callback, so an in-flight identity registration's progress is visible to SwiftUI views via @Query and survives app restarts. Rust FFI: - Add `AssetLockEntryFFI` (`asset_lock_persistence.rs`) — flat C mirror of `AssetLockEntry` with consensus-encoded tx + bincode- encoded proof carried by reference for the callback window. - Add `on_persist_asset_locks_fn` to `PersistenceCallbacks`; wire the dispatcher in `FFIPersister::store()` so every changeset flush forwards asset-lock upserts + removed-outpoint tombstones to Swift. - Extend `WalletRestoreEntryFFI` with `tracked_asset_locks` + `tracked_asset_locks_count`. `build_unused_asset_locks` decodes the persisted rows back into `BTreeMap>` on wallet load so a registration interrupted by an app kill resumes from the latest status without rebroadcasting. SwiftData model: - `PersistentAssetLock` keyed by `outPointHex` (`:`), with `walletId` indexed for per-wallet scans. Mirrors the FFI shape 1:1. - Registered in `DashModelContainer.modelTypes`. - Encode/decode helpers (`encodeOutPoint` / `decodeOutPointHex`) bridge the 36-byte raw form Rust uses to the display-order hex string SwiftData stores. Swift persister: - `PlatformWalletPersistenceHandler.persistAssetLocks` performs insert-or-update by `outPointHex` and deletes by removed outpoints, both inside the bracketed begin/end save round. - `loadCachedAssetLocks` / `buildAssetLockRestoreBuffer` populate the new FFI slice on the load path; the `LoadAllocation` owns the heap buffers until the matching free callback fires. - `persistAssetLocksCallback` C trampoline snapshots every entry into owned `Data` before invoking the handler so Rust's `_storage` Vec can release the buffers as soon as the trampoline returns. Storage explorer: - New "Asset Locks" row in `StorageExplorerView`, list + detail views in `StorageModelListViews` / `StorageRecordDetailViews`. SwiftData-backed; proves the persister round-trip end-to-end before iter 3 part 2 starts consuming the same rows for the progress bar. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/asset_lock_persistence.rs | 173 ++++++++++ packages/rs-platform-wallet-ffi/src/lib.rs | 1 + .../rs-platform-wallet-ffi/src/persistence.rs | 230 ++++++++++++- .../src/wallet_restore_types.rs | 11 + .../Persistence/DashModelContainer.swift | 3 +- .../Models/PersistentAssetLock.swift | 150 ++++++++ .../PlatformWallet/PlatformWalletFFI.swift | 12 + .../PlatformWalletPersistenceHandler.swift | 324 ++++++++++++++++++ .../Views/StorageExplorerView.swift | 6 + .../Views/StorageModelListViews.swift | 71 ++++ .../Views/StorageRecordDetailViews.swift | 52 +++ 11 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs new file mode 100644 index 00000000000..7c98298b180 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs @@ -0,0 +1,173 @@ +//! FFI types for forwarding +//! [`AssetLockChangeSet`](platform_wallet::changeset::AssetLockChangeSet) +//! out of [`FFIPersister`](crate::persistence::FFIPersister) to Swift. +//! +//! Mirrors the shape of the asset-lock changeset emitted by the +//! [`AssetLockManager`](platform_wallet::wallet::asset_lock::AssetLockManager) +//! at every status transition (Built → Broadcast → InstantSendLocked → +//! ChainLocked) and on consumption. Swift maps each upsert onto a +//! `PersistentAssetLock` row keyed by the outpoint and deletes rows +//! for each removed outpoint. +//! +//! ## Ownership +//! +//! [`AssetLockEntryFFI`] points at Rust-owned byte buffers for the +//! consensus-encoded transaction + the bincode-encoded proof. Both +//! live in [`AssetLockEntryStorage`] for the callback window only — +//! Swift must copy whatever bytes it needs before the callback +//! returns. The storage Vec is dropped right after the FFI call, which +//! releases the buffers. + +use bincode::config; +use dashcore::consensus::Encodable; +use dpp::prelude::AssetLockProof; +use platform_wallet::changeset::AssetLockEntry; +use platform_wallet::AssetLockStatus; + +/// Flat C mirror of one [`AssetLockEntry`]. +/// +/// The transaction is consensus-encoded; the optional proof is +/// bincode-encoded with `dpp::bincode::config::standard()`. Both byte +/// slices are Rust-owned for the lifetime of the callback and live in +/// the matching [`AssetLockEntryStorage`]. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AssetLockEntryFFI { + /// Outpoint identifying this credit output: 32-byte txid (raw + /// internal byte order) followed by 4-byte little-endian vout. + /// Matches the serialization used by `PersistentTxo.outpoint`. + pub out_point: [u8; 36], + /// Consensus-encoded asset-lock transaction. Rust-owned, valid only + /// for the callback window. + pub transaction_bytes: *const u8, + pub transaction_bytes_len: usize, + /// BIP44 account index that funded this asset lock. + pub account_index: u32, + /// Discriminant of [`key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType`]: + /// 0 = IdentityRegistration, 1 = IdentityTopUp, 2 = IdentityTopUpNotBound, + /// 3 = IdentityInvitation, 4 = AssetLockAddressTopUp, + /// 5 = AssetLockShieldedAddressTopUp. + pub funding_type: u8, + /// Identity index used during creation. + pub identity_index: u32, + /// Locked amount in duffs (1 DASH = 1e8 duffs). + pub amount_duffs: u64, + /// Discriminant of [`AssetLockStatus`]: + /// 0 = Built, 1 = Broadcast, 2 = InstantSendLocked, 3 = ChainLocked. + pub status: u8, + /// Bincode-encoded [`AssetLockProof`] (standard config). Rust-owned, + /// `null` + `0` length when the entry has no proof yet (statuses + /// Built / Broadcast). + pub proof_bytes: *const u8, + pub proof_bytes_len: usize, +} + +// SAFETY: All pointer fields are Rust-owned and lifetime-scoped to the +// FFI callback. Sending the struct itself is fine; the receiver must +// not retain pointers beyond the callback window. +unsafe impl Send for AssetLockEntryFFI {} +unsafe impl Sync for AssetLockEntryFFI {} + +/// Owned byte buffers backing one [`AssetLockEntryFFI`]'s pointer +/// fields. Kept alive by the callback dispatcher for the callback +/// window via a `Vec` parallel to the +/// `Vec`. +pub struct AssetLockEntryStorage { + pub transaction_bytes: Vec, + pub proof_bytes: Option>, +} + +/// Build a `(Vec, Vec)` pair +/// from the changeset entries. The storage Vec MUST live at least as +/// long as the FFI Vec. +pub fn build_asset_lock_entries( + entries: &[&AssetLockEntry], +) -> (Vec, Vec) { + let mut storage: Vec = Vec::with_capacity(entries.len()); + let mut ffi: Vec = Vec::with_capacity(entries.len()); + + for entry in entries { + let mut transaction_bytes: Vec = Vec::new(); + // `Transaction::consensus_encode` returns `io::Result` and + // never fails when writing to a `Vec`; an Err here would + // mean a logic bug in `dashcore`, not bad input. + entry + .transaction + .consensus_encode(&mut transaction_bytes) + .expect("consensus_encode to Vec is infallible"); + + let proof_bytes: Option> = match &entry.proof { + Some(proof) => Some( + dpp::bincode::encode_to_vec::<&AssetLockProof, _>(proof, config::standard()) + .expect("bincode encoding AssetLockProof is infallible"), + ), + None => None, + }; + + let funding_type = funding_type_to_u8(entry.funding_type); + let status = status_to_u8(&entry.status); + + storage.push(AssetLockEntryStorage { + transaction_bytes, + proof_bytes, + }); + + // Compute the pointers AFTER the storage push, then build the + // FFI entry referencing the just-pushed storage slot. Two + // Vecs grow independently so we cannot mix order — borrow the + // storage slot via index right after the push. + let slot = storage.last().expect("just pushed"); + let (proof_ptr, proof_len) = match &slot.proof_bytes { + Some(bytes) => (bytes.as_ptr(), bytes.len()), + None => (std::ptr::null(), 0usize), + }; + + ffi.push(AssetLockEntryFFI { + out_point: outpoint_to_bytes(&entry.out_point), + transaction_bytes: slot.transaction_bytes.as_ptr(), + transaction_bytes_len: slot.transaction_bytes.len(), + account_index: entry.account_index, + funding_type, + identity_index: entry.identity_index, + amount_duffs: entry.amount_duffs, + status, + proof_bytes: proof_ptr, + proof_bytes_len: proof_len, + }); + } + + (ffi, storage) +} + +/// Encode an [`OutPoint`](dashcore::OutPoint) as 36 bytes: 32-byte raw +/// txid followed by 4-byte little-endian vout. Matches the encoding +/// used by `PersistentTxo.outpoint`. +pub fn outpoint_to_bytes(outpoint: &dashcore::OutPoint) -> [u8; 36] { + let mut bytes = [0u8; 36]; + bytes[..32].copy_from_slice(outpoint.txid.as_ref()); + bytes[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + bytes +} + +fn funding_type_to_u8( + funding_type: key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType, +) -> u8 { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + match funding_type { + AssetLockFundingType::IdentityRegistration => 0, + AssetLockFundingType::IdentityTopUp => 1, + AssetLockFundingType::IdentityTopUpNotBound => 2, + AssetLockFundingType::IdentityInvitation => 3, + AssetLockFundingType::AssetLockAddressTopUp => 4, + AssetLockFundingType::AssetLockShieldedAddressTopUp => 5, + } +} + +fn status_to_u8(status: &AssetLockStatus) -> u8 { + match status { + AssetLockStatus::Built => 0, + AssetLockStatus::Broadcast => 1, + AssetLockStatus::InstantSendLocked => 2, + AssetLockStatus::ChainLocked => 3, + } +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 358354d1eb9..4fc0ce267e2 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -10,6 +10,7 @@ #![allow(clippy::large_enum_variant)] pub mod asset_lock; +pub mod asset_lock_persistence; pub mod contact; pub mod contact_persistence; pub mod contact_request; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4fc7ddba2df..609041c1385 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -28,6 +28,7 @@ use std::ffi::CString; use std::os::raw::c_void; use std::slice; +use crate::asset_lock_persistence::{build_asset_lock_entries, outpoint_to_bytes, AssetLockEntryFFI}; use crate::contact_persistence::{ free_contact_requests_ffi, ContactRequestFFI, ContactRequestRemovalFFI, }; @@ -355,6 +356,27 @@ pub struct PersistenceCallbacks { pub on_get_core_tx_record_free_fn: Option< unsafe extern "C" fn(context: *mut c_void, tx_bytes: *const u8, tx_bytes_len: usize), >, + /// Called with an `AssetLockChangeSet` slice — upserts on the + /// tracked-asset-lock store and outpoint tombstones. Swift maps + /// upserts onto `PersistentAssetLock` rows keyed by the 36-byte + /// outpoint (`txid || vout_le`) and deletes rows for every + /// removal. The `transaction_bytes` / `proof_bytes` slices inside + /// each [`AssetLockEntryFFI`] are Rust-owned and valid only for + /// the callback window — Swift must copy them before returning. + /// + /// Returns 0 on success. A non-zero return flips the round's + /// `success` flag to `false` so [`Self::on_changeset_end_fn`] + /// receives the rollback signal. + pub on_persist_asset_locks_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + upserts_ptr: *const AssetLockEntryFFI, + upserts_count: usize, + removed_ptr: *const [u8; 36], + removed_count: usize, + ) -> i32, + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -732,6 +754,52 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Send asset-lock changeset — tracked-lock upserts (one row + // per credit output, addressed by outpoint) and outpoint + // tombstones (consumed-by-registration removals). Maps onto + // Swift's `PersistentAssetLock` rows. + if let Some(ref al_cs) = changeset.asset_locks { + if let Some(cb) = self.callbacks.on_persist_asset_locks_fn { + let upsert_refs: Vec<&platform_wallet::changeset::AssetLockEntry> = + al_cs.asset_locks.values().collect(); + let (upserts, _storage) = build_asset_lock_entries(&upsert_refs); + let removed: Vec<[u8; 36]> = + al_cs.removed.iter().map(outpoint_to_bytes).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Pin both byte-buffer storage (`_storage`) and + // the FFI Vec until after the callback so the + // pointers stay valid through the C call. + drop(upserts); + drop(_storage); + if result != 0 { + eprintln!( + "Asset lock persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Send DashPay contact-request changeset. // // The flat upsert array is built by walking every source @@ -1987,11 +2055,20 @@ fn build_wallet_start_state( wallet_identities, }; + // Rehydrate tracked asset-locks (built / broadcast / IS-locked + // / chain-locked credit outputs awaiting registration). These + // rows are persisted by `on_persist_asset_locks_fn` whenever the + // asset-lock manager flushes a status change, and the Swift load + // path hands them back here so an in-flight registration that + // was interrupted by an app kill can resume from the latest + // status without rebroadcasting. + let unused_asset_locks = build_unused_asset_locks(entry)?; + let wallet_state = ClientWalletStartState { wallet, wallet_info, identity_manager, - unused_asset_locks: BTreeMap::new(), + unused_asset_locks, }; let platform_address_state = if per_account.is_empty() @@ -2034,6 +2111,157 @@ fn build_wallet_start_state( /// an empty map and gets refreshed on the next sync round — same /// degraded-but-usable behaviour as before this change for that /// narrow case. +/// Rebuild the `unused_asset_locks` map carried on +/// [`ClientWalletStartState`] from the `tracked_asset_locks` slice the +/// Swift load callback hands back. Mirrors the encoding used by +/// [`crate::asset_lock_persistence::build_asset_lock_entries`]: +/// +/// - `out_point` is 32-byte raw txid + 4-byte little-endian vout. +/// - `transaction_bytes` is consensus-encoded. +/// - `proof_bytes` is bincode-encoded (`dpp::bincode::config::standard()`). +/// `null` / 0 length means "no proof yet" (statuses Built / Broadcast). +/// +/// A malformed entry returns `Err(PersistenceError)` so the caller +/// surfaces the load failure rather than dropping a partially-rebuilt +/// state silently. Empty / null `tracked_asset_locks` yields an empty +/// map (same as the legacy hardcoded path). +fn build_unused_asset_locks( + entry: &WalletRestoreEntryFFI, +) -> Result< + BTreeMap>, + PersistenceError, +> { + use dashcore::hashes::Hash; + + let specs: &[AssetLockEntryFFI] = if entry.tracked_asset_locks.is_null() + || entry.tracked_asset_locks_count == 0 + { + &[] + } else { + // SAFETY: Swift guarantees the pointer + count form a valid + // slice for the callback window; this function runs inside + // that window (called from `build_wallet_start_state` invoked + // by `FFIPersister::load`). + unsafe { slice::from_raw_parts(entry.tracked_asset_locks, entry.tracked_asset_locks_count) } + }; + + let mut map: BTreeMap> = + BTreeMap::new(); + for spec in specs { + // Decode the outpoint: 32-byte raw txid + 4-byte LE vout. + let txid = dashcore::Txid::from_slice(&spec.out_point[..32]).map_err(|e| { + PersistenceError::from(format!( + "tracked asset lock: invalid txid in outpoint: {}", + e + )) + })?; + let vout_bytes: [u8; 4] = spec.out_point[32..] + .try_into() + .expect("4-byte slice from 36-byte array"); + let vout = u32::from_le_bytes(vout_bytes); + let out_point = dashcore::OutPoint { txid, vout }; + + // Decode the consensus-encoded transaction. + if spec.transaction_bytes.is_null() || spec.transaction_bytes_len == 0 { + return Err(PersistenceError::from( + "tracked asset lock: empty transaction bytes".to_string(), + )); + } + // SAFETY: Swift guarantees the buffer is valid for the + // callback window. We immediately decode + clone out of it, + // so the lifetime concern is satisfied. + let tx_bytes = + unsafe { slice::from_raw_parts(spec.transaction_bytes, spec.transaction_bytes_len) }; + let transaction: dashcore::Transaction = dashcore::consensus::deserialize(tx_bytes) + .map_err(|e| { + PersistenceError::from(format!( + "tracked asset lock: failed to decode transaction: {}", + e + )) + })?; + + // Decode the optional bincode-encoded proof. + let proof: Option = if spec.proof_bytes.is_null() + || spec.proof_bytes_len == 0 + { + None + } else { + // SAFETY: Same lifetime contract as `transaction_bytes`. + let proof_bytes = + unsafe { slice::from_raw_parts(spec.proof_bytes, spec.proof_bytes_len) }; + let (proof, _) = dpp::bincode::decode_from_slice::< + dpp::prelude::AssetLockProof, + _, + >(proof_bytes, config::standard()) + .map_err(|e| { + PersistenceError::from(format!( + "tracked asset lock: failed to decode proof: {}", + e + )) + })?; + Some(proof) + }; + + let funding_type = funding_type_from_u8(spec.funding_type)?; + let status = status_from_u8(spec.status)?; + + let tracked = platform_wallet::TrackedAssetLock { + out_point, + transaction, + account_index: spec.account_index, + funding_type, + identity_index: spec.identity_index, + amount: spec.amount_duffs, + status, + proof, + }; + map.entry(spec.account_index) + .or_default() + .insert(out_point, tracked); + } + + Ok(map) +} + +fn funding_type_from_u8( + b: u8, +) -> Result< + key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType, + PersistenceError, +> { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + Ok(match b { + 0 => AssetLockFundingType::IdentityRegistration, + 1 => AssetLockFundingType::IdentityTopUp, + 2 => AssetLockFundingType::IdentityTopUpNotBound, + 3 => AssetLockFundingType::IdentityInvitation, + 4 => AssetLockFundingType::AssetLockAddressTopUp, + 5 => AssetLockFundingType::AssetLockShieldedAddressTopUp, + other => { + return Err(PersistenceError::from(format!( + "tracked asset lock: unknown funding_type discriminant {}", + other + ))) + } + }) +} + +fn status_from_u8(b: u8) -> Result { + use platform_wallet::AssetLockStatus; + Ok(match b { + 0 => AssetLockStatus::Built, + 1 => AssetLockStatus::Broadcast, + 2 => AssetLockStatus::InstantSendLocked, + 3 => AssetLockStatus::ChainLocked, + other => { + return Err(PersistenceError::from(format!( + "tracked asset lock: unknown status discriminant {}", + other + ))) + } + }) +} + fn build_wallet_identity_bucket( entry: &WalletRestoreEntryFFI, ) -> Result, PersistenceError> { diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 3e1d82567fa..e6b819add39 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -26,6 +26,7 @@ use std::os::raw::{c_char, c_void}; +use crate::asset_lock_persistence::AssetLockEntryFFI; use crate::platform_address_types::AddressBalanceEntryFFI; use crate::types::FFINetwork; @@ -349,6 +350,16 @@ pub struct WalletRestoreEntryFFI { /// row's `script_pubkey` buffer. pub utxos: *const UtxoRestoreEntryFFI, pub utxos_count: usize, + /// Tracked asset-lock entries persisted by the + /// `on_persist_asset_locks_fn` callback that need to be + /// rehydrated into `ClientWalletStartState.unused_asset_locks` + /// so wallet load resumes mid-flight registrations. + /// + /// Each entry's `transaction_bytes` / `proof_bytes` buffers are + /// Swift-owned and freed by `LoadWalletListFreeFn`. `null` / `0` + /// when the wallet has no persisted tracked locks. + pub tracked_asset_locks: *const AssetLockEntryFFI, + pub tracked_asset_locks_count: usize, } // SAFETY: Pointers are Swift-owned and lifetime-scoped to the callback. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 2623a53f3e1..71a0fcc3c5d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -28,7 +28,8 @@ public enum DashModelContainer { PersistentTransaction.self, PersistentTxo.self, PersistentPendingInput.self, - PersistentWalletManagerMetadata.self + PersistentWalletManagerMetadata.self, + PersistentAssetLock.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift new file mode 100644 index 00000000000..7eaa7be72b5 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -0,0 +1,150 @@ +import Foundation +import SwiftData + +/// SwiftData model for persisting a single tracked asset-lock credit +/// output (DIP-0027). Mirrors +/// [`AssetLockEntry`](platform_wallet::changeset::AssetLockEntry) on +/// the Rust side, one row per `(walletId, outpoint)`. Upserted by the +/// `on_persist_asset_locks_fn` callback whenever the asset-lock +/// manager flushes a status transition (Built → Broadcast → +/// InstantSendLocked → ChainLocked) and deleted when the lock is +/// consumed by a successful identity-registration / top-up flow. +/// +/// Two consumers: +/// 1. `RegistrationProgressView` reads `statusRaw` to drive the +/// stage progress bar (`@Query` filtered by `walletId + +/// identityIndexRaw`). +/// 2. The wallet load path rebuilds `unused_asset_locks` on the +/// Rust side from these rows so an in-flight registration that +/// was interrupted by an app kill can resume from the latest +/// status without rebroadcasting the asset-lock transaction. +@Model +public final class PersistentAssetLock { + /// Index `walletId` so per-wallet asset-lock scans (the progress + /// bar's `@Query`, the storage explorer's wallet-scoped drill- + /// down, the load-time rehydration path) hit an index instead of + /// scanning the whole table. + #Index([\.walletId]) + + /// 36-byte outpoint encoded as `:`. + /// Matches the formatting used by `PersistentTxo.outpointHex` / + /// `PersistentPendingInput`'s outpoint surface so the same lock + /// is identifiable across all three. Unique across the SwiftData + /// store — a collision would imply two wallets producing the + /// same outpoint, which is unreachable in practice. + @Attribute(.unique) public var outPointHex: String + + /// 32-byte wallet id owning this asset lock. + public var walletId: Data + + /// Consensus-encoded asset-lock transaction (Core special + /// transaction type 8, `AssetLockPayload`). Carried so the load + /// path can re-instantiate the `TrackedAssetLock` without + /// rebroadcasting. + public var transactionBytes: Data + + /// Discriminant of [`AssetLockFundingType`]: + /// 0 = IdentityRegistration, 1 = IdentityTopUp, 2 = IdentityTopUpNotBound, + /// 3 = IdentityInvitation, 4 = AssetLockAddressTopUp, + /// 5 = AssetLockShieldedAddressTopUp. Stored as `Int` so SwiftData + /// predicates can compare directly without a cast. + public var fundingTypeRaw: Int + + /// Identity index slot consumed by this asset lock — the source of + /// truth for matching against an in-flight registration's + /// `RegistrationProgressView`. Stored as `Int32` so `#Predicate` + /// can compare against a Swift-side `UInt32` lossily-cast value + /// without overflow surprises (identity indices stay well under + /// `Int32.max`). + public var identityIndexRaw: Int32 + + /// Locked amount in duffs (1 DASH = 1e8 duffs). Stored as `Int64` + /// for the same predicate-friendliness reason as + /// `identityIndexRaw`. + public var amountDuffs: Int64 + + /// Discriminant of [`AssetLockStatus`]: + /// 0 = Built, 1 = Broadcast, 2 = InstantSendLocked, 3 = ChainLocked. + /// Stored as `Int` so `#Predicate` can match raw values directly + /// (the progress bar compares against 0/1/2/3). + public var statusRaw: Int + + /// Bincode-encoded `AssetLockProof` (`dpp::bincode::config::standard()`). + /// Absent (`nil`) until the lock reaches `InstantSendLocked` / + /// `ChainLocked`. The load path passes these bytes back over FFI + /// where Rust decodes them into the live proof. + public var proofBytes: Data? + + /// Record timestamps. + public var createdAt: Date + public var updatedAt: Date + + public init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } +} + +// MARK: - Queries + +extension PersistentAssetLock { + /// Per-wallet predicate. Indexed scan via the `walletId` index. + public static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + /// Per-slot predicate keyed by `(walletId, identityIndex)` — used + /// by `RegistrationProgressView` to find the in-flight lock for + /// a particular registration slot. + public static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +// MARK: - Outpoint encoding helpers + +extension PersistentAssetLock { + /// Encode a 36-byte raw outpoint (`txid_le || vout_le`) — matching + /// the layout used by the Rust-side `AssetLockEntryFFI.out_point` + /// — as the canonical display-order hex string + /// `:`. + /// + /// The Rust side serializes the txid in raw byte order (little- + /// endian on the wire); display order is the reverse, same as + /// `PersistentTxo.outpointHex`. + public static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw in + raw.load(as: UInt32.self).littleEndian + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift index ac6b3fb678f..28061814138 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift @@ -24,6 +24,18 @@ typealias FFIByteTuple20 = ( UInt8, UInt8, UInt8, UInt8 ) +/// 36-byte fixed tuple — used by the asset-lock persister to carry +/// outpoints (32-byte raw txid + 4-byte little-endian vout). Matches +/// the Rust-side `AssetLockEntryFFI.out_point` and the parallel +/// removed-outpoint array on `on_persist_asset_locks_fn`. +typealias FFIByteTuple36 = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8 +) + // MARK: - Mnemonic-resolver callback result codes /// Mirrors the Rust `mnemonic_resolver_result` constants in diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 965e236e55d..bbee23af76f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -113,6 +113,115 @@ public class PlatformWalletPersistenceHandler { } } + // MARK: - Asset locks + + /// Apply an `AssetLockChangeSet` projection to SwiftData. + /// + /// The Rust-side asset-lock manager emits a changeset on every + /// status transition (`Built → Broadcast → InstantSendLocked → + /// ChainLocked`) and on consumption (the registration flow drops + /// the row once the IdentityCreate state transition lands). Each + /// `upsert` maps onto a `PersistentAssetLock` row keyed by + /// `outPointHex` (the 36-byte outpoint encoded as + /// `:`); each `removed` entry deletes the + /// matching row. `RegistrationProgressView` watches these rows + /// via `@Query` to drive the stage progress bar. + /// + /// No `save()` here — bracketed by `beginChangeset` / + /// `endChangeset` from the Rust `store()` round. + func persistAssetLocks( + walletId: Data, + upserts: [AssetLockEntrySnapshot], + removed: [Data] + ) { + onQueue { + for entry in upserts { + let outPointHex = entry.outPointHex + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outPointHex == outPointHex } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + existing.walletId = walletId + existing.transactionBytes = entry.transactionBytes + existing.fundingTypeRaw = entry.fundingTypeRaw + existing.identityIndexRaw = entry.identityIndexRaw + existing.amountDuffs = entry.amountDuffs + existing.statusRaw = entry.statusRaw + existing.proofBytes = entry.proofBytes + existing.updatedAt = Date() + } else { + let record = PersistentAssetLock( + outPointHex: outPointHex, + walletId: walletId, + transactionBytes: entry.transactionBytes, + fundingTypeRaw: entry.fundingTypeRaw, + identityIndexRaw: entry.identityIndexRaw, + amountDuffs: entry.amountDuffs, + statusRaw: entry.statusRaw, + proofBytes: entry.proofBytes + ) + backgroundContext.insert(record) + } + } + + for outPointHex in removed { + let hex = PersistentAssetLock.encodeOutPoint(rawBytes: outPointHex) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outPointHex == hex } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + backgroundContext.delete(existing) + } + } + } + } + + /// Load all persisted tracked asset locks for a wallet — used by + /// the wallet load path to rebuild `unused_asset_locks` on the + /// Rust side so an in-flight registration that was interrupted by + /// an app kill can resume from the latest status without + /// rebroadcasting the asset-lock transaction. + public func loadCachedAssetLocks(walletId: Data) -> [AssetLockEntrySnapshot] { + onQueue { loadCachedAssetLocksOnQueue(walletId: walletId) } + } + + /// On-queue implementation reused by the load-wallet-list path + /// without re-entering `onQueue`. + func loadCachedAssetLocksOnQueue(walletId: Data) -> [AssetLockEntrySnapshot] { + let descriptor = FetchDescriptor( + predicate: PersistentAssetLock.predicate(walletId: walletId) + ) + guard let records = try? backgroundContext.fetch(descriptor) else { + return [] + } + return records.map { record in + AssetLockEntrySnapshot( + outPointHex: record.outPointHex, + transactionBytes: record.transactionBytes, + fundingTypeRaw: record.fundingTypeRaw, + identityIndexRaw: record.identityIndexRaw, + amountDuffs: record.amountDuffs, + statusRaw: record.statusRaw, + proofBytes: record.proofBytes + ) + } + } + + /// Owned snapshot of an `AssetLockEntryFFI` row. Same lifetime + /// rationale as `IdentityEntrySnapshot` — the callback copies + /// every byte buffer into owned `Data` before invoking the + /// handler, so the handler runs against pure-Swift values + /// regardless of when the Rust-side allocation gets reclaimed. + public struct AssetLockEntrySnapshot { + public let outPointHex: String + public let transactionBytes: Data + public let fundingTypeRaw: Int + public let identityIndexRaw: Int32 + public let amountDuffs: Int64 + public let statusRaw: Int + public let proofBytes: Data? + } + /// Load all cached platform-address balances for a wallet. Tuple /// shape matches the Rust-side `AddressBalanceEntryFFI` layout so /// the load-wallet-list path can re-seed the provider on startup @@ -799,6 +908,7 @@ public class PlatformWalletPersistenceHandler { cb.on_persist_identity_keys_fn = persistIdentityKeysCallback cb.on_persist_token_balances_fn = persistTokenBalancesCallback cb.on_persist_contacts_fn = persistContactsCallback + cb.on_persist_asset_locks_fn = persistAssetLocksCallback cb.on_get_core_tx_record_fn = getCoreTxRecordCallback cb.on_get_core_tx_record_free_fn = getCoreTxRecordFreeCallback return cb @@ -2439,6 +2549,20 @@ public class PlatformWalletPersistenceHandler { } entry.utxos = utxoBuf.map { UnsafePointer($0) } entry.utxos_count = UInt(utxoCount) + + // Tracked asset-lock rows. The Rust side rehydrates these + // into `unused_asset_locks` so an in-flight registration + // that was killed mid-flight can resume from the latest + // status without rebroadcasting. Empty / null when the + // wallet has no persisted locks. + let assetLockRows = loadCachedAssetLocksOnQueue(walletId: w.walletId) + let (assetLockBuf, assetLockCount) = buildAssetLockRestoreBuffer( + rows: assetLockRows, + allocation: allocation + ) + entry.tracked_asset_locks = assetLockBuf.map { UnsafePointer($0) } + entry.tracked_asset_locks_count = UInt(assetLockCount) + // Primary-identity selection + gap-limit scan watermark // were dropped from the FFI shape — both moved off the // Rust manager (UI owns selection now, scan resume is @@ -2591,6 +2715,128 @@ public class PlatformWalletPersistenceHandler { return (buf, written, false) } + /// Build a contiguous `[AssetLockEntryFFI]` buffer for one wallet's + /// tracked asset locks. Walks `PersistentAssetLock` rows scoped to + /// `walletId`, copies the consensus-encoded transaction + optional + /// bincode-encoded proof into Swift-owned heap buffers, and emits + /// one row per lock. Returns `(nil, 0)` for empty input — Rust + /// treats `null` + `count == 0` as "no tracked locks to restore". + /// + /// Per-row transaction/proof buffers and the outer array are + /// tracked on `allocation` so `loadWalletListFree` releases them. + /// Rows whose `outPointHex` doesn't parse back to 36 bytes are + /// skipped — the model writes them in a known shape, so a + /// mismatch indicates corruption that would crash Rust's decoder + /// anyway. + private func buildAssetLockRestoreBuffer( + rows: [AssetLockEntrySnapshot], + allocation: LoadAllocation + ) -> (UnsafeMutablePointer?, Int) { + if rows.isEmpty { + return (nil, 0) + } + let buf = UnsafeMutablePointer.allocate(capacity: rows.count) + var written = 0 + for record in rows { + // Parse `:` back into the 36-byte raw form + // the Rust side expects. Any parse failure drops the row + // — we can't manufacture a valid outpoint and a malformed + // row indicates an old / corrupt snapshot. + guard let outPoint = decodeOutPointHex(record.outPointHex) else { + NSLog( + "[persistor-load:swift] dropping asset-lock row with malformed outPointHex: %@", + record.outPointHex + ) + continue + } + + // Allocate + copy the transaction bytes (Rust-owned for + // the callback window via the allocation). + let txBytes = record.transactionBytes + let txPtr: UnsafePointer? + let txLen = txBytes.count + if txLen > 0 { + let buffer = UnsafeMutablePointer.allocate(capacity: txLen) + txBytes.copyBytes(to: buffer, count: txLen) + allocation.scalarBuffers.append((buffer, txLen)) + txPtr = UnsafePointer(buffer) + } else { + // A row with no transaction bytes is broken — Rust's + // load path will reject it; drop here. + NSLog( + "[persistor-load:swift] dropping asset-lock row with empty transactionBytes: %@", + record.outPointHex + ) + continue + } + + // Optional proof bytes. + let proofPtr: UnsafePointer? + let proofLen: Int + if let bytes = record.proofBytes, !bytes.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: bytes.count) + bytes.copyBytes(to: buffer, count: bytes.count) + allocation.scalarBuffers.append((buffer, bytes.count)) + proofPtr = UnsafePointer(buffer) + proofLen = bytes.count + } else { + proofPtr = nil + proofLen = 0 + } + + var entry = AssetLockEntryFFI() + copyBytes(outPoint, into: &entry.out_point) + entry.transaction_bytes = txPtr + entry.transaction_bytes_len = UInt(txLen) + // `accountIndex` isn't stored on the SwiftData model + // (Rust derives it from the funding path), so default to + // 0. The Rust load path doesn't read this field for + // anything load-bearing — it's a breadcrumb for the + // FFI persist path going forward. + entry.account_index = 0 + entry.funding_type = UInt8(clamping: record.fundingTypeRaw) + entry.identity_index = UInt32(bitPattern: record.identityIndexRaw) + entry.amount_duffs = UInt64(bitPattern: record.amountDuffs) + entry.status = UInt8(clamping: record.statusRaw) + entry.proof_bytes = proofPtr + entry.proof_bytes_len = UInt(proofLen) + buf[written] = entry + written += 1 + } + if written == 0 { + buf.deallocate() + return (nil, 0) + } + allocation.assetLockArrays.append((buf, written)) + return (buf, written) + } + + /// Parse `:` back into the 36-byte + /// raw outpoint Rust expects (32-byte raw txid + 4-byte + /// little-endian vout). Mirror of + /// `PersistentAssetLock.encodeOutPoint`. Returns `nil` for any + /// parse failure. + private func decodeOutPointHex(_ hex: String) -> Data? { + let parts = hex.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let txidHex = String(parts[0]) + guard let vout = UInt32(parts[1]) else { return nil } + guard txidHex.count == 64 else { return nil } + var txid = Data(capacity: 32) + var idx = txidHex.startIndex + for _ in 0..<32 { + let end = txidHex.index(idx, offsetBy: 2) + guard let byte = UInt8(txidHex[idx.., Int)] = [] + /// Per-wallet `AssetLockEntryFFI` arrays. The transaction-bytes + /// and proof-bytes buffers each row references live in + /// `scalarBuffers`. + var assetLockArrays: [(UnsafeMutablePointer, Int)] = [] func release() { if let entries = entries { @@ -2982,6 +3232,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in assetLockArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } } } @@ -3511,6 +3765,76 @@ private func persistTokenBalancesCallback( return 0 } +/// C shim for `on_persist_asset_locks_fn`. Copies every +/// `AssetLockEntryFFI` row + every removed-outpoint tuple into +/// Swift-owned `Data` snapshots before invoking the handler so the +/// Rust-side `_storage` Vec can release the byte buffers as soon as +/// this trampoline returns. +private func persistAssetLocksCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + upsertsPtr: UnsafePointer?, + upsertsCount: UInt, + removedPtr: UnsafePointer?, + removedCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var upserts: [PlatformWalletPersistenceHandler.AssetLockEntrySnapshot] = [] + if upsertsCount > 0, let upsertsPtr = upsertsPtr { + upserts.reserveCapacity(Int(upsertsCount)) + for i in 0.. 0 { + txBytes = Data(bytes: ptr, count: Int(e.transaction_bytes_len)) + } else { + txBytes = Data() + } + // Optional bincode-encoded proof. + let proofBytes: Data? + if let ptr = e.proof_bytes, e.proof_bytes_len > 0 { + proofBytes = Data(bytes: ptr, count: Int(e.proof_bytes_len)) + } else { + proofBytes = nil + } + upserts.append(.init( + outPointHex: outPointHex, + transactionBytes: txBytes, + fundingTypeRaw: Int(e.funding_type), + identityIndexRaw: Int32(bitPattern: e.identity_index), + amountDuffs: Int64(bitPattern: e.amount_duffs), + statusRaw: Int(e.status), + proofBytes: proofBytes + )) + } + } + + var removed: [Data] = [] + if removedCount > 0, let removedPtr = removedPtr { + removed.reserveCapacity(Int(removedCount)) + for i in 0.. { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentAssetLock] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List(visible) { record in + NavigationLink(destination: AssetLockStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.outPointHex) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1).truncationMode(.middle) + HStack(spacing: 8) { + Text(statusLabel(record.statusRaw)) + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text("identity #\(record.identityIndexRaw)") + .font(.caption2) + .foregroundColor(.secondary) + Text(record.updatedAt, style: .relative) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Asset Locks (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView( + "No Asset Locks", + systemImage: "lock.shield" + ) + } + } + } + + private func statusLabel(_ raw: Int) -> String { + switch raw { + case 0: return "Built" + case 1: return "Broadcast" + case 2: return "InstantSendLocked" + case 3: return "ChainLocked" + default: return "Unknown(\(raw))" + } + } +} + // MARK: - PersistentWalletManagerMetadata struct WalletManagerMetadataStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 5a5a947a89c..76aee66e289 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1774,3 +1774,55 @@ struct WalletManagerMetadataStorageDetailView: View { .navigationBarTitleDisplayMode(.inline) } } + +struct AssetLockStorageDetailView: View { + let record: PersistentAssetLock + + var body: some View { + Form { + Section("Asset Lock") { + FieldRow(label: "Outpoint", value: record.outPointHex) + FieldRow(label: "Status", value: statusLabel(record.statusRaw)) + FieldRow(label: "Funding Type", value: fundingTypeLabel(record.fundingTypeRaw)) + FieldRow(label: "Identity Index", value: "\(record.identityIndexRaw)") + FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + } + Section("Bytes") { + FieldRow(label: "Transaction Bytes", value: "\(record.transactionBytes.count) bytes") + FieldRow( + label: "Proof Bytes", + value: record.proofBytes.map { "\($0.count) bytes" } ?? "—" + ) + } + Section("Timestamps") { + FieldRow(label: "Created", value: dateString(record.createdAt)) + FieldRow(label: "Updated", value: dateString(record.updatedAt)) + } + } + .navigationTitle("Asset Lock") + .navigationBarTitleDisplayMode(.inline) + } + + private func statusLabel(_ raw: Int) -> String { + switch raw { + case 0: return "Built" + case 1: return "Broadcast" + case 2: return "InstantSendLocked" + case 3: return "ChainLocked" + default: return "Unknown(\(raw))" + } + } + + private func fundingTypeLabel(_ raw: Int) -> String { + switch raw { + case 0: return "IdentityRegistration" + case 1: return "IdentityTopUp" + case 2: return "IdentityTopUpNotBound" + case 3: return "IdentityInvitation" + case 4: return "AssetLockAddressTopUp" + case 5: return "AssetLockShieldedAddressTopUp" + default: return "Unknown(\(raw))" + } + } +} From c7b06bb03f26d71390131abcf9863bf0bd01e9f8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 04:09:44 +0700 Subject: [PATCH 11/54] feat(SwiftExampleApp): stage progress bar + RegistrationCoordinator for identity registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces iter-1's single in-flight spinner with a 5-step stage-aware progress UI that survives view dismissal and supports multiple concurrent registrations. Services: - `IdentityRegistrationController` (`@MainActor`, ObservableObject) owns the per-slot registration phase: .idle → .preparingKeys → .inFlight → .completed(id) | .failed(message). Single-flighted inside `submit` so a re-submit on an active controller is a no-op. - `RegistrationCoordinator` (hosted on `PlatformWalletManager` via an associated-object extension — keeps example-app types out of the SDK module while preserving the plan's call-site convention) maps `(walletId, identityIndex) → IdentityRegistrationController`, auto-purges `.completed` rows ~30s after success, keeps `.failed` rows until manually dismissed, and exposes `hasInFlightRegistrations` for the network-toggle gate. Views: - `RegistrationProgressView` derives the current step from `controller.phase` (steps 1, 4, 5) combined with a live `@Query` on `PersistentAssetLock` filtered by `(walletId, identityIndex)` (steps 2/3, driven by `statusRaw`). 5-row list with done/active/pending/failed states and inline error message on failure. - `PendingRegistrationsList` + `PendingRegistrationRow` surface the coordinator's active controllers in `IdentitiesContentView`. Dismissed-but-still-running flows remain reachable via tap; `.failed` rows can be dismissed via swipe action. Wiring: - `CreateIdentityView.submitCoreFunded` binds the FFI call into `coordinator.startRegistration(...)` and observes the controller's phase transitions via a small AsyncStream poller (no Combine — `AnyCancellable` isn't Sendable from `AsyncStream`'s `@Sendable` builder closure). Local `createdIdentityId` / `submitError` / `isCreating` mirrors update from the observer so the existing success / error UI keeps working when the user stays on the sheet. - `OptionsView`'s network picker `.disabled(_:)` includes `hasInFlightRegistrations` so switching networks mid-flight doesn't tear down the FFI manager (the adversarial-review concern from the plan). A small footer explains why the picker is grayed out. Both gates use a dedicated sub-view / ViewModifier observing the coordinator as `@ObservedObject` so the reactive update fires on phase transitions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 26 +++ .../IdentityRegistrationController.swift | 106 +++++++++ ...alletManager+RegistrationCoordinator.swift | 53 +++++ .../Services/RegistrationCoordinator.swift | 158 +++++++++++++ .../Views/CreateIdentityView.swift | 129 ++++++++-- .../SwiftExampleApp/Views/OptionsView.swift | 54 ++++- .../Views/PendingRegistrationsList.swift | 94 ++++++++ .../Views/RegistrationProgressView.swift | 221 ++++++++++++++++++ 8 files changed, 820 insertions(+), 21 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+RegistrationCoordinator.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 36164a25941..36a027b4995 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -11,6 +11,7 @@ import SwiftData struct IdentitiesContentView: View { @EnvironmentObject var platformState: AppState @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService + @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext @Query(sort: \PersistentIdentity.identityIndex) private var identities: [PersistentIdentity] @@ -25,6 +26,7 @@ struct IdentitiesContentView: View { var body: some View { List { + pendingRegistrationsSection if identities.isEmpty { Section { VStack(spacing: 12) { @@ -157,6 +159,30 @@ struct IdentitiesContentView: View { } } + /// "Pending registrations" row group. Surfaces every controller + /// the `RegistrationCoordinator` is tracking — both in-flight + /// flows (the user dismissed `CreateIdentityView` but the + /// registration is still running) and terminal-but-undismissed + /// flows (`.completed` rows linger ~30s, `.failed` rows linger + /// indefinitely until the user manually dismisses). Empty when + /// the coordinator's map is empty, in which case the section + /// collapses to nothing so the rest of the screen isn't pushed + /// down by an "empty" header. + /// + /// Observation: wrap the coordinator in a dedicated + /// `PendingRegistrationsList` so its `@ObservedObject` reads + /// the coordinator directly. Reading + /// `walletManager.registrationCoordinator` inside this view's + /// body would not subscribe — `walletManager` is the + /// `EnvironmentObject` we observe, not the coordinator hung off + /// it — so map mutations wouldn't trigger a redraw. + @ViewBuilder + private var pendingRegistrationsSection: some View { + PendingRegistrationsList( + coordinator: walletManager.registrationCoordinator + ) + } + /// Short title for the removal confirmation dialog. Uses /// `displayName` so the user sees the DPNS name / alias when /// available, and a truncated-hex id otherwise — matches the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift new file mode 100644 index 00000000000..5f19b1650f6 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftDashSDK + +/// Per-slot state owned by a single identity registration attempt. +/// +/// One controller is created per `(walletId, identityIndex)` slot +/// when the user submits `CreateIdentityView`. The controller owns +/// the in-flight `Task`, exposes its current `phase` via +/// `@Published`, and survives view dismissal via +/// `RegistrationCoordinator` on `PlatformWalletManager`. Multiple +/// controllers can be active simultaneously (one per slot); each +/// runs on `@MainActor` so SwiftUI observers see consistent +/// transitions. +/// +/// The 5-step progress bar in `RegistrationProgressView` derives its +/// step from a combination of `phase` (Step 1, 4, 5) and the live +/// `PersistentAssetLock` row queried via `@Query` filtered by +/// `(walletId, identityIndex)` (Step 2/3, driven by `statusRaw`). +@MainActor +final class IdentityRegistrationController: ObservableObject { + enum Phase: Equatable { + /// Pre-submit. The controller exists but `submit` hasn't + /// fired yet. Not surfaced by `RegistrationProgressView` + /// (the view only opens after a submit). + case idle + /// Step 1: pre-deriving + Keychain-persisting the identity + /// keys via `prePersistIdentityKeysForRegistration`. The + /// caller drives this transition before invoking `submit`. + case preparingKeys + /// Steps 2–4 inclusive: the FFI registration call is in + /// flight. Stage within this phase is read from the + /// matching `PersistentAssetLock.statusRaw` row. + case inFlight + /// Step 5: identity is registered. `identityId` is the + /// 32-byte identifier the caller should persist / + /// navigate to. + case completed(identityId: Data) + /// Failure terminal state. The message is shown inline in + /// `RegistrationProgressView`'s step 5; the row stays in + /// the coordinator's map until the user dismisses it + /// manually. + case failed(String) + } + + /// Current phase. Updates flow: + /// `.idle` → `.preparingKeys` (caller) → `.inFlight` (submit) → + /// `.completed(id) | .failed(message)`. + @Published private(set) var phase: Phase = .idle + + /// Slot this controller is bound to. Stored so the coordinator + /// and the progress view can filter `PersistentAssetLock` rows + /// by `(walletId, identityIndex)`. + let walletId: Data + let identityIndex: UInt32 + + /// Timestamp of the most recent `submit` call. Used by the + /// coordinator's TTL-based retention policy (`.completed` rows + /// purge ~30s after the success transition). + private(set) var lastSubmittedAt: Date? + + /// Active registration task. Holds a reference so the + /// coordinator's stash retains the work until completion; + /// cancellation isn't wired today (the FFI call doesn't yet + /// support clean abort), but the field lets future work hang + /// off the same shape. + private var task: Task? + + init(walletId: Data, identityIndex: UInt32) { + self.walletId = walletId + self.identityIndex = identityIndex + } + + /// Transition to `.preparingKeys`. Called by the caller before + /// `submit` while it pre-derives the identity public keys via + /// `prePersistIdentityKeysForRegistration`. + func enterPreparingKeys() { + phase = .preparingKeys + } + + /// Submit the registration. Single-flighted by the coordinator: + /// callers should check `phase != .inFlight` before invoking, + /// otherwise the controller silently ignores re-submits to keep + /// the FFI call exclusive. + /// + /// `body` performs the actual FFI call. It runs detached on a + /// background priority and reports the identity id on success + /// or rethrows on failure. The controller flips `phase` to + /// `.completed` / `.failed` accordingly. + func submit(body: @escaping () async throws -> Data) { + guard phase != .inFlight else { return } + phase = .inFlight + lastSubmittedAt = Date() + task = Task { [weak self] in + do { + let identityId = try await body() + await MainActor.run { + self?.phase = .completed(identityId: identityId) + } + } catch { + await MainActor.run { + self?.phase = .failed(error.localizedDescription) + } + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+RegistrationCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+RegistrationCoordinator.swift new file mode 100644 index 00000000000..a401ac67316 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/PlatformWalletManager+RegistrationCoordinator.swift @@ -0,0 +1,53 @@ +import Foundation +import ObjectiveC +import SwiftDashSDK + +/// Per-manager `RegistrationCoordinator` accessor. Lazy-initialized +/// on first access and lifetime-tied to the +/// [`PlatformWalletManager`](SwiftDashSDK.PlatformWalletManager) +/// instance via an `objc_getAssociatedObject` slot. +/// +/// Why this shape: the coordinator is example-app-only state (it +/// stores `IdentityRegistrationController` instances, which live in +/// the app, not the SDK), but the call site convention from the +/// plan reads as `walletManager.registrationCoordinator.startRegistration(...)`. +/// Storing it directly on the SDK type would push the controller +/// type into `SwiftDashSDK`, which violates the architectural rule +/// in `swift-sdk/CLAUDE.md` ("SDK does persist / load / bridge — no +/// business logic"). The associated-object hook keeps the call site +/// clean while leaving the SDK module untouched. +/// +/// The coordinator's lifetime matches the manager's: when +/// `WalletManagerStore` deallocs an inactive manager (none does +/// today, but in principle), the associated object is released +/// alongside it. Switching networks at runtime tears down the +/// active manager and any in-flight registrations belong to the +/// prior network anyway — this matches the +/// `hasInFlightRegistrations` gate on the network toggle in +/// `CoreContentView`. +@MainActor +extension PlatformWalletManager { + /// Backing key for the associated-object slot. A static address + /// is required by the runtime; `let _key = UInt8(0)` produces a + /// stable per-program-address that's unique to this extension. + private static var coordinatorKey: UInt8 = 0 + + /// Per-manager registration coordinator. Created on first + /// access; subsequent reads return the same instance. + var registrationCoordinator: RegistrationCoordinator { + if let existing = objc_getAssociatedObject( + self, + &PlatformWalletManager.coordinatorKey + ) as? RegistrationCoordinator { + return existing + } + let fresh = RegistrationCoordinator() + objc_setAssociatedObject( + self, + &PlatformWalletManager.coordinatorKey, + fresh, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return fresh + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift new file mode 100644 index 00000000000..414bab65eb1 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift @@ -0,0 +1,158 @@ +import Foundation +import SwiftDashSDK + +/// Singleton hub for in-flight identity registrations, hosted on +/// `PlatformWalletManager` so registrations survive view dismissal +/// and network-toggle pressure. +/// +/// Why on PlatformWalletManager (not AppState): +/// - `PlatformWalletManager` is the per-network operational hub +/// and outlives any individual view's lifetime. +/// - `AppState` is a bootstrap host whose lifetime is the whole +/// app, but it doesn't own the wallet / FFI handle the +/// registration call needs. +/// - Registration state has the same lifetime as the +/// wallet/network pairing: switching networks blows away the +/// manager and any in-flight registrations belong to the prior +/// network anyway. +/// +/// Keyed by `(walletId, identityIndex)`. The slot model enforces +/// "one registration in flight per identity slot" naturally because +/// the wallet's `unusedIdentityIndices` invariant only ever +/// surfaces a slot to the UI once. +@MainActor +final class RegistrationCoordinator: ObservableObject { + /// Composite key — needs `Hashable` so the map can index by it. + /// The `walletId` is treated as 32 raw bytes; the `identityIndex` + /// is the HD slot the caller is registering against. + struct SlotKey: Hashable { + let walletId: Data + let identityIndex: UInt32 + } + + /// Active controllers keyed by slot. Stored as `@Published` so + /// the "Pending registrations" row on the identities tab can + /// observe map mutations via `objectWillChange`. + @Published private(set) var controllers: [SlotKey: IdentityRegistrationController] = [:] + + /// True when at least one slot is currently in flight (phase + /// `.preparingKeys` or `.inFlight`). Used by the network + /// toggle's `.disabled(_:)` modifier — switching testnet ↔ + /// mainnet mid-flight tears down the FFI manager and would + /// abort the in-flight call mid-stream. The UI guards against + /// that race by reading this flag. + var hasInFlightRegistrations: Bool { + controllers.contains { _, controller in + switch controller.phase { + case .preparingKeys, .inFlight: + return true + default: + return false + } + } + } + + /// Look up the controller for a slot if one exists. Returns + /// `nil` when there's no active registration for the slot — + /// callers use that to decide whether to spawn a new controller + /// or reuse the existing one. + func controller(walletId: Data, identityIndex: UInt32) -> IdentityRegistrationController? { + controllers[SlotKey(walletId: walletId, identityIndex: identityIndex)] + } + + /// Snapshot of every active controller, sorted by recency of + /// last submit (most recent first). Used by the "Pending + /// registrations" row so dismissed-but-still-running flows + /// remain reachable. + func activeControllers() -> [IdentityRegistrationController] { + controllers.values.sorted { lhs, rhs in + (lhs.lastSubmittedAt ?? .distantPast) > (rhs.lastSubmittedAt ?? .distantPast) + } + } + + /// Start a registration for the slot, or reuse an existing + /// controller if one is already in flight for it. Returns the + /// controller for `CreateIdentityView` to bind a + /// `RegistrationProgressView` against. + /// + /// Single-flighting is handled inside + /// `IdentityRegistrationController.submit` — a second call for + /// the same slot while the first is in flight is silently + /// ignored at the controller layer. + func startRegistration( + walletId: Data, + identityIndex: UInt32, + body: @escaping () async throws -> Data + ) -> IdentityRegistrationController { + let key = SlotKey(walletId: walletId, identityIndex: identityIndex) + let controller: IdentityRegistrationController + if let existing = controllers[key] { + controller = existing + } else { + controller = IdentityRegistrationController( + walletId: walletId, + identityIndex: identityIndex + ) + controllers[key] = controller + } + controller.enterPreparingKeys() + controller.submit(body: body) + scheduleRetentionSweep(key: key, controller: controller) + return controller + } + + /// Manually drop a controller from the map. Used by the UI's + /// "Dismiss" action on a `.failed` row (failures stay + /// indefinitely until acknowledged so the user can read the + /// error). + func dismiss(walletId: Data, identityIndex: UInt32) { + let key = SlotKey(walletId: walletId, identityIndex: identityIndex) + controllers.removeValue(forKey: key) + } + + // MARK: - Retention sweep + + /// Auto-purge `.completed` controllers ~30s after the success + /// transition so the home tab's pending list doesn't accumulate + /// stale rows. `.failed` controllers stay indefinitely until + /// the user dismisses them (their error message is the only + /// surface where the failure is reported). + private func scheduleRetentionSweep( + key: SlotKey, + controller: IdentityRegistrationController + ) { + // Observe phase transitions on the controller and arm a + // 30s sweep after a `.completed` flip. `Combine`'s sink is + // overkill for a single observer — we poll via a Task that + // re-checks every second until either: + // - the controller is gone (already dismissed), or + // - 30s have elapsed since the success transition. + Task { [weak self, weak controller] in + guard let controller = controller else { return } + var completedAt: Date? + while !Task.isCancelled { + let phase = await MainActor.run { controller.phase } + switch phase { + case .completed: + if completedAt == nil { + completedAt = Date() + } else if let at = completedAt, + Date().timeIntervalSince(at) >= 30 { + await MainActor.run { + _ = self?.controllers.removeValue(forKey: key) + } + return + } + case .failed: + // Keep indefinitely; the user dismisses manually + // via the "Dismiss" action. Return so the poll + // loop doesn't spin. + return + default: + completedAt = nil + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index eabcc127870..99d78d5d7f5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -597,7 +597,16 @@ struct CreateIdentityView: View { /// transaction from BIP44 account #0's UTXOs (mempool `account_index = 0` /// in `create_funded_asset_lock_proof`), broadcasts it, waits for /// the instant-send lock, and submits the IdentityCreate state - /// transition. Iter 1: single in-flight spinner, no stage UI. + /// transition. + /// + /// Iter 3 swap (the previous iter-1 path was a single in-flight + /// spinner inside this view): the FFI call moves to a + /// `RegistrationCoordinator`-hosted `IdentityRegistrationController` + /// hung off the `PlatformWalletManager`. The view binds the + /// "Registering…" indicator to the controller's `phase` so + /// dismissing the sheet doesn't abort the registration — + /// `RegistrationProgressView` can be opened from the home / + /// identities tab to follow the same controller through. private func submitCoreFunded( walletId: Data, identityIndex: UInt32, @@ -613,35 +622,115 @@ struct CreateIdentityView: View { isCreating = true - Task { - do { + // Reuse an existing controller for this slot if one is in + // flight (the coordinator's single-flight gate makes the + // re-submit a no-op at the controller layer), otherwise + // spawn a fresh one. The persistence side-effects + // (`persistCreatedIdentity`, `markIdentitySlotUsed`) live + // here in the view so we still have access to + // `modelContext` / `platformState`; the controller's body + // is responsible only for the FFI call + the success + // identifier. + let coordinator = walletManager.registrationCoordinator + let controller = coordinator.startRegistration( + walletId: walletId, + identityIndex: identityIndex, + body: { let (identityId, _) = try await managedWallet.registerIdentityWithFunding( amountDuffs: amountDuffs, identityIndex: identityIndex, identityPubkeys: identityPubkeys, signer: signer ) + return identityId + } + ) - try await MainActor.run { - try persistCreatedIdentity( - identityId: identityId, - network: network - ) - markIdentitySlotUsed( - walletId: walletId, - identityIndex: identityIndex - ) - try modelContext.save() - self.createdIdentityId = identityId + // Observe phase transitions to mirror onto this view's + // local success / error state. The controller stays in the + // coordinator independently of this view's lifetime, so + // the same flow remains reachable from + // `RegistrationProgressView` after dismissal. + observeController( + controller, + walletId: walletId, + identityIndex: identityIndex, + network: network + ) + } + + /// Bridge a controller's phase transitions to this view's + /// `createdIdentityId` / `submitError` / `isCreating` state. + /// The observer task auto-cancels when this view deallocates + /// (Swift task lifecycle on the captured `self`), but the + /// controller itself outlives the view. + private func observeController( + _ controller: IdentityRegistrationController, + walletId: Data, + identityIndex: UInt32, + network: Network + ) { + Task { + for await phase in phaseStream(controller: controller) { + switch phase { + case .completed(let identityId): + do { + try persistCreatedIdentity( + identityId: identityId, + network: network + ) + markIdentitySlotUsed( + walletId: walletId, + identityIndex: identityIndex + ) + try modelContext.save() + self.createdIdentityId = identityId + } catch { + self.submitError = .init( + message: error.localizedDescription + ) + } self.isCreating = false - } - } catch { - await MainActor.run { - self.submitError = .init( - message: error.localizedDescription - ) + return + case .failed(let message): + self.submitError = .init(message: message) self.isCreating = false + return + default: + continue + } + } + } + } + + /// Poll the controller's `phase` until it reaches a terminal + /// state (`.completed` / `.failed`) and yield each transition. + /// Combine's `$phase.values` would be more idiomatic, but + /// `AnyCancellable` isn't `Sendable` and the @Sendable closure + /// of `AsyncStream`'s builder rejects it. A small polling loop + /// avoids the bridge entirely. + private func phaseStream( + controller: IdentityRegistrationController + ) -> AsyncStream { + AsyncStream { continuation in + Task { @MainActor in + var lastEmitted: IdentityRegistrationController.Phase? = nil + while !Task.isCancelled { + let phase = controller.phase + if phase != lastEmitted { + continuation.yield(phase) + lastEmitted = phase + } + switch phase { + case .completed, .failed: + continuation.finish() + return + default: + break + } + try? await Task.sleep(nanoseconds: 100_000_000) } + continuation.finish() } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index a4729c489ae..cd13c72926e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -130,8 +130,24 @@ struct OptionsView: View { Text(network.displayName).tag(network) } } + // Disable + footer driven by the in-flight gate; + // wrapped in a sub-view so the + // `RegistrationCoordinator`'s `@Published` + // mutations are observed reactively (a plain + // `walletManager.registrationCoordinator.hasInFlightRegistrations` + // read here wouldn't subscribe — the env object + // is `walletManager`, not the coordinator). .pickerStyle(SegmentedPickerStyle()) - .disabled(isSwitchingNetwork) + .modifier( + NetworkPickerInFlightGate( + coordinator: walletManager.registrationCoordinator, + isSwitching: isSwitchingNetwork + ) + ) + + NetworkInFlightFooter( + coordinator: walletManager.registrationCoordinator + ) if appState.currentNetwork == .regtest { Toggle("Use Docker Setup", isOn: $appState.useDockerSetup) @@ -648,3 +664,39 @@ struct FeatureRow: View { } } } + +/// ViewModifier wrapper that observes the +/// `RegistrationCoordinator` so the picker's `.disabled(_:)` state +/// updates reactively when registrations start or finish. A direct +/// read of `coordinator.hasInFlightRegistrations` from the parent +/// view's `body` would not subscribe to the coordinator's +/// `@Published` changes — the env object is the wallet manager, not +/// the coordinator hung off it. +private struct NetworkPickerInFlightGate: ViewModifier { + @ObservedObject var coordinator: RegistrationCoordinator + let isSwitching: Bool + + func body(content: Content) -> some View { + content.disabled(isSwitching || coordinator.hasInFlightRegistrations) + } +} + +/// Inline footer telling the user why the network picker is grayed +/// out. Visible only while at least one registration is in flight; +/// observes the coordinator the same way `NetworkPickerInFlightGate` +/// does so the footer disappears the moment the last registration +/// terminates. +private struct NetworkInFlightFooter: View { + @ObservedObject var coordinator: RegistrationCoordinator + + var body: some View { + if coordinator.hasInFlightRegistrations { + Text( + "Network switching is disabled while an identity " + + "registration is in flight." + ) + .font(.caption2) + .foregroundColor(.orange) + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift new file mode 100644 index 00000000000..4cbb55509e9 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingRegistrationsList.swift @@ -0,0 +1,94 @@ +import SwiftUI +import SwiftDashSDK + +/// Section wrapper that observes a `RegistrationCoordinator` directly +/// so its `@Published controllers` map mutations trigger SwiftUI +/// re-renders. Hosted inside `IdentitiesContentView` — separated out +/// because nesting an `@ObservedObject` inside a `@ViewBuilder` +/// computed property doesn't subscribe (the property recomputes per +/// parent body call, never owning a stable reference). +struct PendingRegistrationsList: View { + @ObservedObject var coordinator: RegistrationCoordinator + + var body: some View { + let active = coordinator.activeControllers() + if !active.isEmpty { + Section("Pending Registrations") { + ForEach(active, id: \.identityIndex) { controller in + PendingRegistrationRow(controller: controller) + } + } + } + } +} + +/// Single row in the "Pending Registrations" list. Each row hangs off +/// an `IdentityRegistrationController` via `@ObservedObject` so the +/// status label updates as the controller's phase transitions; tapping +/// the row navigates into `RegistrationProgressView` so the user can +/// follow the stage progression in detail. +struct PendingRegistrationRow: View { + @ObservedObject var controller: IdentityRegistrationController + @EnvironmentObject var walletManager: PlatformWalletManager + + var body: some View { + NavigationLink(destination: RegistrationProgressView(controller: controller)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: phaseIcon) + .foregroundColor(phaseTint) + Text("Identity #\(controller.identityIndex)") + .font(.body) + Spacer() + Text(phaseLabel) + .font(.caption) + .foregroundColor(.secondary) + } + Text(controller.walletId.prefix(8).map { String(format: "%02x", $0) }.joined() + "…") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .padding(.vertical, 2) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if case .failed = controller.phase { + Button { + walletManager.registrationCoordinator.dismiss( + walletId: controller.walletId, + identityIndex: controller.identityIndex + ) + } label: { + Label("Dismiss", systemImage: "xmark") + } + .tint(.gray) + } + } + } + + private var phaseIcon: String { + switch controller.phase { + case .idle, .preparingKeys, .inFlight: return "clock.fill" + case .completed: return "checkmark.circle.fill" + case .failed: return "xmark.octagon.fill" + } + } + + private var phaseTint: Color { + switch controller.phase { + case .idle, .preparingKeys, .inFlight: return .blue + case .completed: return .green + case .failed: return .red + } + } + + private var phaseLabel: String { + switch controller.phase { + case .idle: return "Queued" + case .preparingKeys: return "Preparing keys" + case .inFlight: return "In flight" + case .completed: return "Registered" + case .failed: return "Failed" + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift new file mode 100644 index 00000000000..cd2b4f69493 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift @@ -0,0 +1,221 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// 5-step stage-aware progress UI for an in-flight identity +/// registration. Drilled into from `CreateIdentityView`'s submit +/// section or from the "Pending registrations" row on the +/// identities tab. +/// +/// Step mapping (from the plan, iter 3 part 2): +/// +/// 1. Preparing identity keys → controller `.preparingKeys` +/// 2. Building asset-lock tx → activeLock `statusRaw == 0` +/// 3. Broadcasting & waiting → activeLock `statusRaw == 1` +/// 4. Submitting to Platform → activeLock `statusRaw == 2 or 3` +/// AND controller still `.inFlight` +/// 5. Identity registered → controller `.completed` +/// +/// `.failed` aliases to step 5 with the error message inline. +struct RegistrationProgressView: View { + @ObservedObject var controller: IdentityRegistrationController + + /// Asset-lock rows for this slot, queried live so step 2/3/4 + /// transitions are reactive without polling. The predicate is + /// keyed by the same `(walletId, identityIndex)` tuple the + /// coordinator uses. + @Query private var activeLocks: [PersistentAssetLock] + + init(controller: IdentityRegistrationController) { + self.controller = controller + let walletId = controller.walletId + let identityIndex = controller.identityIndex + _activeLocks = Query( + filter: PersistentAssetLock.predicate( + walletId: walletId, + identityIndex: identityIndex + ), + sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)] + ) + } + + var body: some View { + let step = currentStep + let isFailed = isFailed + let errorMessage = failureMessage + + Form { + Section { + ForEach(1...5, id: \.self) { idx in + stepRow( + index: idx, + title: stepTitle(idx), + state: stepState(idx, currentStep: step, isFailed: isFailed) + ) + if idx == 5, let message = errorMessage { + Text(message) + .font(.caption) + .foregroundColor(.red) + .padding(.leading, 32) + } + } + } header: { + Text("Registration Progress") + } footer: { + Text(footerText(step: step, isFailed: isFailed)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .navigationTitle("Registration") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Step computation + + /// 1...5, current active step (or 5 on terminal states). + private var currentStep: Int { + switch controller.phase { + case .idle, .preparingKeys: + return 1 + case .completed: + return 5 + case .failed: + // Pick the step at which we failed by reading the + // latest known lock status. The terminal indicator + // (red on step 5) is what the user sees, but the + // partial fill of earlier steps tells them how far + // we got. + if let lock = activeLocks.first { + switch lock.statusRaw { + case 0: return 2 + case 1: return 3 + case 2, 3: return 4 + default: return 1 + } + } + return 5 + case .inFlight: + guard let lock = activeLocks.first else { + // No lock row yet — Rust has the slot but hasn't + // emitted the first changeset. We're still + // logically in step 2 (building the asset-lock + // tx). + return 2 + } + switch lock.statusRaw { + case 0: + return 2 + case 1: + return 3 + case 2, 3: + return 4 + default: + return 2 + } + } + } + + private var isFailed: Bool { + if case .failed = controller.phase { return true } + return false + } + + private var failureMessage: String? { + if case .failed(let msg) = controller.phase { return msg } + return nil + } + + private func stepTitle(_ idx: Int) -> String { + switch idx { + case 1: return "Preparing identity keys" + case 2: return "Building asset-lock transaction" + case 3: return "Broadcasting & waiting for InstantSend lock" + case 4: return "Submitting to Platform" + case 5: return "Identity registered" + default: return "" + } + } + + /// Step-state classification. Drives the icon + tint on the + /// row. + enum StepState { case done, active, pending, failed } + + private func stepState(_ idx: Int, currentStep: Int, isFailed: Bool) -> StepState { + if isFailed && idx == currentStep { + return .failed + } + if idx < currentStep { + return .done + } + if idx == currentStep { + return .active + } + return .pending + } + + // MARK: - Row UI + + @ViewBuilder + private func stepRow(index: Int, title: String, state: StepState) -> some View { + HStack(spacing: 12) { + stepIcon(index: index, state: state) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout) + .foregroundColor(stepTextColor(state)) + } + Spacer() + } + } + + @ViewBuilder + private func stepIcon(index: Int, state: StepState) -> some View { + switch state { + case .done: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title3) + case .active: + ProgressView() + .scaleEffect(0.7) + .frame(width: 22, height: 22) + case .pending: + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.4), lineWidth: 1) + .frame(width: 22, height: 22) + Text("\(index)") + .font(.caption2) + .foregroundColor(.secondary) + } + case .failed: + Image(systemName: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.title3) + } + } + + private func stepTextColor(_ state: StepState) -> Color { + switch state { + case .done: return .primary + case .active: return .primary + case .pending: return .secondary + case .failed: return .red + } + } + + private func footerText(step: Int, isFailed: Bool) -> String { + if isFailed { + return "Tap Dismiss in Pending Registrations to clear this entry." + } + switch step { + case 1: return "Deriving keys locally and persisting to the Keychain." + case 2: return "Building a Core asset-lock transaction from wallet funds." + case 3: return "Waiting for the InstantSend lock so the asset-lock proof is final." + case 4: return "Submitting the IdentityCreate state transition to Platform." + case 5: return "Identity registered." + default: return "" + } + } +} From 6acbd01b273df9292b568ef5d1463e7cd861247c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 04:09:59 +0700 Subject: [PATCH 12/54] docs: mark iter 3 complete Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index ecc1d30d8d8..3acd9a5d535 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -1,6 +1,6 @@ # Create Identity from Core Funds — Plan (Draft 9) -Status: **iter 1 + 2 + 4 done. Testnet validation hit an SPV event-routing concern (separate investigation). iter 3 (SwiftData mirror) and iter 5 (resume picker) still pending.** +Status: **iter 1 + 2 + 3 + 4 done. iter 5 (resume picker) still pending.** Branch: `feat/swift/funding-with-asset-lock` Target: SwiftExampleApp, testnet validation first. From 37dfc49e20f879fc6d88283df09c7cda7db97db9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 05:46:56 +0700 Subject: [PATCH 13/54] wip(SwiftExampleApp): refactor identity-create flow + 5-step progress (incomplete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterative changes on the identity-creation UX, checkpointed mid-debug. Working: - Progress section refactored to 5 steps: Building → Broadcasting → Wait IS → Wait CL → Registering identity. `RegistrationProgressSection` is embeddable (no nested-`Form`); `RegistrationProgressView` is the standalone navigation destination. - `TimelineView(.periodic)` drives the Broadcasting → Wait-IS → Wait-CL transition within `statusRaw == 1` using elapsed time as the anchor. Step 4 (Wait CL) renders as `.skipped` when the IS branch finalised the lock. - Success state moved to `RegistrationProgressView.terminalSection` with a single "View" button (no separate "Done"). Tapping calls back through `onViewIdentity` to the parent and dismisses the sheet; the parent's `.navigationDestination(item:)` pushes `IdentityDetailView`. - `IdentityStorageDetailView`: top-level "View Identity" link to the operational identity detail. - `AssetLockStorageDetailView`: separate "Identity" section with a single-row `NavigationLink` to the linked identity (Base58 id), visible only for `IdentityRegistration` / `IdentityTopUp` funding types. Known broken: `CreateIdentityView`'s Source Wallet `Picker` is disabled / not responding to taps on the simulator. Likely caused by the new `.navigationDestination(isPresented:)` modifier or its interaction with the parent's `.navigationDestination(item:)`. The log shows `<0x...> Gesture: System gesture gate timed out`, meaning the main thread fails to respond to the tap. Wrapping the parent's nav target in an `Identifiable` shim (`CreatedIdentityNavTarget`) was attempted but didn't help. Committing as a checkpoint so the work isn't lost; the picker regression is the next thing to debug. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 31 +- .../Views/CreateIdentityView.swift | 124 +++++--- .../Views/RegistrationProgressView.swift | 268 ++++++++++++++---- .../Views/StorageRecordDetailViews.swift | 69 +++++ 4 files changed, 399 insertions(+), 93 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 36a027b4995..94dce2c3017 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -18,6 +18,24 @@ struct IdentitiesContentView: View { @State private var showingLoadIdentity = false @State private var showingCreateIdentity = false @State private var showingSearchWallets = false + /// Identity id captured from `CreateIdentityView`'s "View" + /// button after a successful registration. Setting this drives + /// the `.navigationDestination` push to `IdentityDetailView`. + /// Wrapped in a small `Identifiable` shim so the destination + /// API has a stable per-id identity to diff on — using `Data` + /// directly caused a SwiftUI hit-test glitch where Pickers on + /// the sheet stopped responding to taps. + @State private var navigateToCreatedIdentity: CreatedIdentityNavTarget? + + /// Item shim for `.navigationDestination(item:)`. Carries the + /// raw identity id and exposes itself as `Identifiable` via a + /// `UUID` so SwiftUI's navigation diffing treats each + /// "register-then-view" cycle as a distinct push, even when + /// the user retries with the same identity id. + struct CreatedIdentityNavTarget: Identifiable, Hashable { + let id = UUID() + let identityId: Data + } /// Identity targeted by a pending Remove swipe. Non-nil presents /// the confirmation dialog below. Stored as a reference rather than /// an id so the dialog can show the display name / truncated id @@ -148,8 +166,17 @@ struct IdentitiesContentView: View { .environmentObject(platformState) } .sheet(isPresented: $showingCreateIdentity) { - CreateIdentityView() - .environmentObject(platformState) + CreateIdentityView(onViewIdentity: { identityId in + // The sheet has already dismissed itself; capture + // the id so the navigationDestination below pushes + // `IdentityDetailView` once the sheet animation + // tears down. + navigateToCreatedIdentity = CreatedIdentityNavTarget(identityId: identityId) + }) + .environmentObject(platformState) + } + .navigationDestination(item: $navigateToCreatedIdentity) { target in + IdentityDetailView(identityId: target.identityId) } .sheet(isPresented: $showingSearchWallets) { SearchWalletsForIdentitiesView() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 99d78d5d7f5..3b63fa86bf2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -25,6 +25,14 @@ struct CreateIdentityView: View { @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState + /// Called when the user taps "View" on the success state. + /// Carries the newly created identity id back to the parent + /// (typically `IdentitiesContentView`), which is expected to + /// (a) dismiss this sheet and (b) push `IdentityDetailView` + /// in its own `NavigationStack`. Empty default makes the view + /// usable in places that don't need post-create navigation. + var onViewIdentity: (Data) -> Void = { _ in } + /// Default number of Platform identity authentication keys to /// register in this first-pass flow. First key is MASTER, the /// rest are HIGH. Advanced override is intentionally not exposed @@ -117,28 +125,57 @@ struct CreateIdentityView: View { /// the submit section swaps to a success banner and auto-dismiss. @State private var createdIdentityId: Data? = nil + /// Active registration controller for the Core-funded path + /// while the registration is in flight. Set in + /// `submitCoreFunded`; drives the pushed + /// `RegistrationProgressView` destination. Cleared on + /// terminal phase by `observeController`. + @State private var activeController: IdentityRegistrationController? = nil + + /// Toggle for the pushed registration-progress destination. + /// `submit()` sets this to `true` once the coordinator has + /// spawned a controller; the dedicated screen then owns the + /// rest of the user flow (progress steps + success / error + + /// "View Identity" navigation). + @State private var showProgressDestination: Bool = false + var body: some View { NavigationStack { Form { - // Once registration succeeds, collapse the form down to - // just the success banner — the input sections (funding - // source, amount, registration-index stepper with its - // now-irrelevant collision warning) are noise at that - // point. - if createdIdentityId != nil { - successSection - } else { - sourceWalletSection - fundingSection - amountSection - identityIndexSection - if canSubmit { - submitSection - } + sourceWalletSection + fundingSection + amountSection + identityIndexSection + if canSubmit { + submitSection } } .navigationTitle("Create Identity") .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $showProgressDestination) { + // Standalone progress screen for the spawned + // controller. Owns the 5-step progress UI and the + // terminal success / failure sections. The "View" + // button on success closes the sheet AND tells + // the parent which identity to navigate to via + // `onViewIdentity`. + if let controller = activeController { + RegistrationProgressView( + controller: controller, + onViewIdentity: { identityId in + // Tell the parent first (it stores + // the id and triggers its own + // navigation), then dismiss the + // sheet. SwiftUI processes the state + // update before the dismissal + // animation completes, so the parent + // is already primed to push. + onViewIdentity(identityId) + dismiss() + } + ) + } + } .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } @@ -349,6 +386,11 @@ struct CreateIdentityView: View { } private var submitSection: some View { + // The active progress / success / error UI lives on the + // pushed `RegistrationProgressView` destination, NOT + // inline. Once `submit()` spawns a controller and flips + // `showProgressDestination` to true, the user is navigated + // there and this form steps off-screen. Section { Button { submit() @@ -370,32 +412,12 @@ struct CreateIdentityView: View { } } - /// Success banner + "Done" button shown after the identity is - /// registered and persisted. Replaces the submit section so - /// the user can't accidentally double-submit. - private var successSection: some View { - Section { - VStack(alignment: .leading, spacing: 8) { - Label("Identity created", systemImage: "checkmark.seal.fill") - .foregroundColor(.green) - .font(.headline) - if let id = createdIdentityId { - Text(id.toBase58String()) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(.secondary) - .textSelection(.enabled) - } - Button { - dismiss() - } label: { - Text("Done") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding(.top, 4) - } - } - } + // `successSection` was removed when the dedicated + // `RegistrationProgressView` destination took ownership of the + // success / failure terminal UI (including the "View Identity" + // navigation). The form here is input-only; submission pushes + // to the progress destination, which renders the post- + // registration state. // MARK: - Derived state @@ -646,6 +668,14 @@ struct CreateIdentityView: View { } ) + // Capture the controller for the pushed + // `RegistrationProgressView` destination, then trigger the + // navigation. The destination owns the progress UI + the + // success/failure terminal state from here on; this view + // becomes a no-op until the user pops back. + self.activeController = controller + self.showProgressDestination = true + // Observe phase transitions to mirror onto this view's // local success / error state. The controller stays in the // coordinator independently of this view's lifetime, so @@ -691,10 +721,22 @@ struct CreateIdentityView: View { ) } self.isCreating = false + // Do NOT clear `activeController` here — the + // pushed `RegistrationProgressView` destination + // is bound to `if let controller = activeController`, + // so nilling it tears the destination down and + // SwiftUI pops back to the form before the user + // sees the success state. The controller lives + // on `walletManager.registrationCoordinator` + // anyway and is purged by the coordinator's + // ~30 s post-completion retention. return case .failed(let message): self.submitError = .init(message: message) self.isCreating = false + // Same rationale as `.completed`: keep the + // controller so the destination can render + // the inline failure state. return default: continue diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift index cd2b4f69493..36a97f01841 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift @@ -2,30 +2,58 @@ import SwiftUI import SwiftData import SwiftDashSDK -/// 5-step stage-aware progress UI for an in-flight identity -/// registration. Drilled into from `CreateIdentityView`'s submit -/// section or from the "Pending registrations" row on the -/// identities tab. +/// Embeddable 5-step progress section. Use inside any parent +/// `Form` so the progress UI doesn't nest a second `Form`, which +/// SwiftUI doesn't render cleanly. For the standalone navigation +/// destination see `RegistrationProgressView` below. /// -/// Step mapping (from the plan, iter 3 part 2): +/// Step mapping: /// -/// 1. Preparing identity keys → controller `.preparingKeys` -/// 2. Building asset-lock tx → activeLock `statusRaw == 0` -/// 3. Broadcasting & waiting → activeLock `statusRaw == 1` -/// 4. Submitting to Platform → activeLock `statusRaw == 2 or 3` -/// AND controller still `.inFlight` -/// 5. Identity registered → controller `.completed` +/// 1. Building asset-lock tx → activeLock `statusRaw == 0` +/// 2. Broadcasting → activeLock `statusRaw == 1` and +/// < `broadcastingWindow` since +/// the row's `updatedAt` +/// 3. Waiting for InstantSend proof → activeLock `statusRaw == 1` and +/// between `broadcastingWindow` +/// and `instantLockTimeout` +/// 4. Waiting for ChainLock proof → activeLock `statusRaw == 1` and +/// >= `instantLockTimeout` (the +/// Rust side has fallen back to +/// ChainLock); also done when +/// `statusRaw == 3` because that +/// proof type finalised the lock +/// 5. Registering identity → activeLock `statusRaw == 2 or 3` +/// AND controller still `.inFlight` /// -/// `.failed` aliases to step 5 with the error message inline. -struct RegistrationProgressView: View { +/// `.completed` is the *terminal* state and is not a separate step; +/// `RegistrationProgressView` renders the "Identity created" banner +/// + "View Identity" navigation below this section in its own +/// terminalSection. `.failed` marks the current step with the error +/// icon + message. Step 4 is shown as `.skipped` (faded checkmark) +/// when the IS branch came back fast so the user can see step 4 was +/// passed through without engaging the CL fallback. +struct RegistrationProgressSection: View { @ObservedObject var controller: IdentityRegistrationController - /// Asset-lock rows for this slot, queried live so step 2/3/4 - /// transitions are reactive without polling. The predicate is - /// keyed by the same `(walletId, identityIndex)` tuple the - /// coordinator uses. + /// Asset-lock rows for this slot, queried live so step 2/3/4/5 + /// transitions are reactive to status changes without polling. @Query private var activeLocks: [PersistentAssetLock] + /// Cutoff (seconds since the row transitioned to `Broadcast`) + /// between the visually-brief "Broadcasting" step (3) and the + /// "Waiting for InstantSend proof" step (4). Tuned short so + /// step 3 doesn't visually linger — by the time the user sees + /// the page, the broadcast is already on the wire. + private static let broadcastingWindow: TimeInterval = 2.0 + + /// Cutoff (seconds since `Broadcast`) where the Rust side falls + /// back from InstantSend to ChainLock. Mirrors + /// `AssetLockManager`'s 300 s IS wait. If `statusRaw == 1` is + /// still the state after this window, the wallet is in the CL + /// fallback window (180 s); we mark step 4 done and step 5 + /// active to communicate the shift. + private static let instantLockTimeout: TimeInterval = 300.0 + init(controller: IdentityRegistrationController) { self.controller = controller let walletId = controller.walletId @@ -40,11 +68,16 @@ struct RegistrationProgressView: View { } var body: some View { - let step = currentStep - let isFailed = isFailed - let errorMessage = failureMessage + // `TimelineView` re-fires the body every 1 s so the + // elapsed-time heuristic that distinguishes step 2 / 3 / 4 + // refreshes without an external timer. The lock row's + // `updatedAt` is the anchor. + TimelineView(.periodic(from: .now, by: 1.0)) { timeline in + let now = timeline.date + let step = currentStep(now: now) + let isFailed = isFailed + let errorMessage = failureMessage - Form { Section { ForEach(1...5, id: \.self) { idx in stepRow( @@ -67,30 +100,37 @@ struct RegistrationProgressView: View { .foregroundColor(.secondary) } } - .navigationTitle("Registration") - .navigationBarTitleDisplayMode(.inline) } // MARK: - Step computation - /// 1...5, current active step (or 5 on terminal states). - private var currentStep: Int { + /// 1...5, current active step. On `.completed` we report 6 (one + /// past the last visual step) so all rows render as `.done`; + /// the terminal "Identity created" banner is rendered by the + /// parent `RegistrationProgressView`, not by this section. + /// `now` is the time-of-rendering, used to drive the + /// Broadcasting → Waiting-IS → Waiting-CL transition within + /// `statusRaw == 1`. + private func currentStep(now: Date) -> Int { switch controller.phase { case .idle, .preparingKeys: return 1 case .completed: - return 5 + // No visible "registered" step — terminalSection on + // `RegistrationProgressView` carries that state. Return + // 6 so every step row (1...5) is marked `.done`. + return 6 case .failed: // Pick the step at which we failed by reading the // latest known lock status. The terminal indicator - // (red on step 5) is what the user sees, but the - // partial fill of earlier steps tells them how far - // we got. + // (red on the failed step) is what the user sees, + // but the partial fill of earlier steps tells them + // how far we got. if let lock = activeLocks.first { switch lock.statusRaw { - case 0: return 2 - case 1: return 3 - case 2, 3: return 4 + case 0: return 1 + case 1: return broadcastSubStep(for: lock, now: now) + case 2, 3: return 5 default: return 1 } } @@ -99,23 +139,51 @@ struct RegistrationProgressView: View { guard let lock = activeLocks.first else { // No lock row yet — Rust has the slot but hasn't // emitted the first changeset. We're still - // logically in step 2 (building the asset-lock + // logically in step 1 (building the asset-lock // tx). - return 2 + return 1 } switch lock.statusRaw { case 0: - return 2 + return 1 case 1: - return 3 - case 2, 3: - return 4 + return broadcastSubStep(for: lock, now: now) + case 2: + // InstantSend-locked. We never went through step 4 + // (CL fallback); it stays as `.skipped`. Step 5 + // active. + return 5 + case 3: + // ChainLock-locked. Both step 3 and step 4 done + // (CL fallback path). Step 5 active. + return 5 default: - return 2 + return 1 } } } + /// Resolve which of steps 2/3/4 is "active" while the lock is + /// at `statusRaw == 1`. Uses elapsed time since the row's last + /// update as the anchor: brief broadcasting window first, then + /// IS wait until the Rust-side timeout, then the CL fallback. + private func broadcastSubStep(for lock: PersistentAssetLock, now: Date) -> Int { + let elapsed = now.timeIntervalSince(lock.updatedAt) + if elapsed < Self.broadcastingWindow { return 2 } + if elapsed < Self.instantLockTimeout { return 3 } + return 4 + } + + /// True when step 4 should appear "skipped" rather than + /// "active" — i.e. the lock came back InstantSend-locked + /// (statusRaw == 2) so the CL fallback was never needed. Drives + /// a distinct pending visual on step 4 in the success path + /// without bleeding into other gray rows. + private var step4WasSkipped: Bool { + guard let lock = activeLocks.first else { return false } + return lock.statusRaw == 2 + } + private var isFailed: Bool { if case .failed = controller.phase { return true } return false @@ -128,24 +196,33 @@ struct RegistrationProgressView: View { private func stepTitle(_ idx: Int) -> String { switch idx { - case 1: return "Preparing identity keys" - case 2: return "Building asset-lock transaction" - case 3: return "Broadcasting & waiting for InstantSend lock" - case 4: return "Submitting to Platform" - case 5: return "Identity registered" + case 1: return "Building asset-lock transaction" + case 2: return "Broadcasting" + case 3: return "Waiting for InstantSend proof" + case 4: return "Waiting for ChainLock proof" + case 5: return "Registering identity" default: return "" } } /// Step-state classification. Drives the icon + tint on the - /// row. - enum StepState { case done, active, pending, failed } + /// row. `.skipped` is a softer pending variant for step 5 when + /// the IS branch returned the proof without needing ChainLock + /// fallback — visually distinguishable so users don't think + /// the step "didn't happen yet" once we've moved past it. + enum StepState { case done, active, pending, skipped, failed } private func stepState(_ idx: Int, currentStep: Int, isFailed: Bool) -> StepState { if isFailed && idx == currentStep { return .failed } if idx < currentStep { + // Step 4 is the only one that can be "skipped" while + // a later step is active — when the IS path returned + // the proof and ChainLock fallback was never engaged. + if idx == 4 && step4WasSkipped { + return .skipped + } return .done } if idx == currentStep { @@ -189,6 +266,14 @@ struct RegistrationProgressView: View { .font(.caption2) .foregroundColor(.secondary) } + case .skipped: + // Lighter checkmark to communicate "we passed this + // step but didn't need it" — typically step 5 when + // the InstantSend branch returned the proof and the + // ChainLock fallback was never engaged. + Image(systemName: "checkmark.circle") + .foregroundColor(.secondary) + .font(.title3) case .failed: Image(systemName: "xmark.octagon.fill") .foregroundColor(.red) @@ -201,6 +286,7 @@ struct RegistrationProgressView: View { case .done: return .primary case .active: return .primary case .pending: return .secondary + case .skipped: return .secondary case .failed: return .red } } @@ -210,12 +296,94 @@ struct RegistrationProgressView: View { return "Tap Dismiss in Pending Registrations to clear this entry." } switch step { - case 1: return "Deriving keys locally and persisting to the Keychain." - case 2: return "Building a Core asset-lock transaction from wallet funds." + case 1: return "Building a Core asset-lock transaction from wallet funds." + case 2: return "Sending the asset-lock transaction to peers." case 3: return "Waiting for the InstantSend lock so the asset-lock proof is final." - case 4: return "Submitting the IdentityCreate state transition to Platform." - case 5: return "Identity registered." + case 4: return "InstantSend timed out; falling back to ChainLock finality (~2 min)." + case 5: return "Submitting the IdentityCreate state transition to Platform." default: return "" } } } + +/// Standalone navigation destination for a registration in flight, +/// completed, or failed. Pushed from `CreateIdentityView` on submit +/// and from the "Pending Registrations" row on the identities tab. +/// Renders the 7-step progress, plus the terminal section on +/// `.completed` (success banner + "View Identity" navigation) or +/// `.failed` (inline error). Embedders that already render a +/// `Form` should use `RegistrationProgressSection` directly. +struct RegistrationProgressView: View { + @ObservedObject var controller: IdentityRegistrationController + + /// Tap handler for the "View" button on success. Called with + /// the newly created identity id. The parent flow is expected + /// to (a) dismiss any presenting sheet and (b) navigate to + /// `IdentityDetailView` in its own NavigationStack. Empty + /// default for the standalone Pending-Registrations entry + /// point, where there's no sheet to dismiss. + var onViewIdentity: (Data) -> Void = { _ in } + + init( + controller: IdentityRegistrationController, + onViewIdentity: @escaping (Data) -> Void = { _ in } + ) { + self.controller = controller + self.onViewIdentity = onViewIdentity + } + + var body: some View { + Form { + RegistrationProgressSection(controller: controller) + terminalSection + } + .navigationTitle("Registration") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var terminalSection: some View { + switch controller.phase { + case .completed(let identityId): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Identity created", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + Text(identityId.toBase58String()) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + .textSelection(.enabled) + Button { + // Close the create-identity sheet and let + // the parent push `IdentityDetailView` in + // its own NavigationStack. The handoff + // happens in `IdentitiesContentView` via + // a `(walletId, identityId)` state binding + // wired to a `.navigationDestination`. + onViewIdentity(identityId) + } label: { + Text("View") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Registration failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + default: + EmptyView() + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 76aee66e289..e5cf3b07100 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -57,6 +57,22 @@ struct IdentityStorageDetailView: View { var body: some View { Form { + Section { + // Surface the operational identity view from the + // storage explorer — the StorageExplorer page is + // metadata-only; the live view (balance, top-up, + // documents, etc.) lives in IdentityDetailView. + NavigationLink { + IdentityDetailView(identityId: record.identityId) + } label: { + HStack { + Text("View Identity") + Spacer() + Image(systemName: "arrow.right") + .foregroundColor(.secondary) + } + } + } Section("Core") { FieldRow(label: "ID (Base58)", value: record.identityIdBase58) FieldRow(label: "ID (Hex)", value: record.identityIdString) @@ -1778,6 +1794,32 @@ struct WalletManagerMetadataStorageDetailView: View { struct AssetLockStorageDetailView: View { let record: PersistentAssetLock + /// Look up the operational identity at this asset lock's + /// `(walletId, identityIndex)` slot. Populated only for + /// IdentityRegistration / IdentityTopUp funding types — the + /// other funding types (invitation / asset-lock-address top-up) + /// don't bind to a specific identity slot on this wallet. + @Query private var linkedIdentities: [PersistentIdentity] + + init(record: PersistentAssetLock) { + self.record = record + let walletId = record.walletId + // `PersistentAssetLock.identityIndexRaw` is `Int32` (the + // changeset FFI uses i32 to match the upstream tracked + // type), but `PersistentIdentity.identityIndex` is `UInt32` + // (the DIP-9 slot is unsigned). Bridge with a `UInt32` + // cast captured in Swift before predicate construction — + // SwiftData's `#Predicate` macro doesn't allow inline + // conversions inside the closure body. + let identityIndex = UInt32(bitPattern: record.identityIndexRaw) + _linkedIdentities = Query( + filter: #Predicate { identity in + identity.wallet?.walletId == walletId + && identity.identityIndex == identityIndex + } + ) + } + var body: some View { Form { Section("Asset Lock") { @@ -1788,6 +1830,25 @@ struct AssetLockStorageDetailView: View { FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") FieldRow(label: "Wallet ID", value: hexString(record.walletId)) } + if isIdentityFunding, let identity = linkedIdentities.first { + // Bridge to the operational identity detail view + // from inside the storage page. Separate section + // so the row is visually clearly a navigation + // affordance — a single `NavigationLink` chevron, + // not mixed in with the plain `FieldRow` text rows + // above. + Section("Identity") { + NavigationLink { + IdentityDetailView(identityId: identity.identityId) + } label: { + Text(identity.identityIdBase58) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } Section("Bytes") { FieldRow(label: "Transaction Bytes", value: "\(record.transactionBytes.count) bytes") FieldRow( @@ -1825,4 +1886,12 @@ struct AssetLockStorageDetailView: View { default: return "Unknown(\(raw))" } } + + /// True when this asset lock funded an identity at a specific + /// `(walletId, identityIndex)` slot — i.e. registration or + /// top-up. The other funding types don't deterministically + /// resolve to a single identity on this wallet. + private var isIdentityFunding: Bool { + record.fundingTypeRaw == 0 || record.fundingTypeRaw == 1 + } } From f62b65cf5c1fc0ef5e227bf3cc1fe0c64af421f9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 14:18:29 +0700 Subject: [PATCH 14/54] fix(SwiftExampleApp): inline progress + Done dismiss + storage status fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 3 polish round, salvaged from the WIP commit that hit a Picker hit-test regression on iOS 26. CreateIdentityView: - Inline progress: the Form swaps in `RegistrationProgressSection` + a terminal `Done` banner when `activeController` is set, replacing the input sections in place. The "Done" button on success now also calls `walletManager.registrationCoordinator.dismiss(...)` so the "Pending Registrations" row on the Identities tab clears immediately rather than waiting ~30 s for the retention sweep. - Dropped the in-flight `.fullScreenCover` / `.navigationDestination` experiments. Both modifiers broke `Picker` hit-testing inside the sheet on iOS 26 (Source Wallet "Select…" rendered but didn't respond to taps). Reverting to inline rendering keeps the picker interactive without losing the new-screen feel — the Form's sections are swapped wholesale on submit. IdentitiesContentView: - Dropped `navigateToCreatedIdentity` state + the `.navigationDestination(item:)` modifier that paired with CreateIdentityView's now-removed `onViewIdentity` callback. RegistrationProgressView: - Standalone `Done` button (the success state reachable from Pending Registrations) drops the controller from the coordinator before popping, matching the inline path. - Reverted to a plain `Done` button (was a "View Identity" link briefly during the new-screen iteration); `View Identity` only makes sense in the sheet flow and that flow is gone. AssetLockStorageDetailView: - Identity is now its own `Section("Identity")` with the linked identity rendered as a copyable static `Text`. Pushing `IdentityDetailView` from this nested Settings → Storage → Asset Locks → Asset Lock path hung the main thread on iOS 26 even after the HStack/contentShape workaround the rest of the codebase uses elsewhere; punting on navigation here keeps the page usable. The operational identity view is still reachable from the Identities tab. - Predicate relaxed: candidate identities are queried by `identityIndex` alone, then post-filtered in Swift preferring a strict `(walletId, identityIndex)` match and falling back to a single orphaned (wallet == nil) identity. The previous strict predicate silently hid legacy identities whose `wallet` relationship was never persisted. - For partial / unconsumed asset locks (no linked identity), the section shows a status fallback ("In progress" / "Pending (unused)") so the entry isn't blank. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 31 +--- .../Views/CreateIdentityView.swift | 145 +++++++++++------- .../Views/RegistrationProgressView.swift | 35 ++--- .../Views/StorageRecordDetailViews.swift | 87 ++++++++--- 4 files changed, 172 insertions(+), 126 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 94dce2c3017..36a027b4995 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -18,24 +18,6 @@ struct IdentitiesContentView: View { @State private var showingLoadIdentity = false @State private var showingCreateIdentity = false @State private var showingSearchWallets = false - /// Identity id captured from `CreateIdentityView`'s "View" - /// button after a successful registration. Setting this drives - /// the `.navigationDestination` push to `IdentityDetailView`. - /// Wrapped in a small `Identifiable` shim so the destination - /// API has a stable per-id identity to diff on — using `Data` - /// directly caused a SwiftUI hit-test glitch where Pickers on - /// the sheet stopped responding to taps. - @State private var navigateToCreatedIdentity: CreatedIdentityNavTarget? - - /// Item shim for `.navigationDestination(item:)`. Carries the - /// raw identity id and exposes itself as `Identifiable` via a - /// `UUID` so SwiftUI's navigation diffing treats each - /// "register-then-view" cycle as a distinct push, even when - /// the user retries with the same identity id. - struct CreatedIdentityNavTarget: Identifiable, Hashable { - let id = UUID() - let identityId: Data - } /// Identity targeted by a pending Remove swipe. Non-nil presents /// the confirmation dialog below. Stored as a reference rather than /// an id so the dialog can show the display name / truncated id @@ -166,17 +148,8 @@ struct IdentitiesContentView: View { .environmentObject(platformState) } .sheet(isPresented: $showingCreateIdentity) { - CreateIdentityView(onViewIdentity: { identityId in - // The sheet has already dismissed itself; capture - // the id so the navigationDestination below pushes - // `IdentityDetailView` once the sheet animation - // tears down. - navigateToCreatedIdentity = CreatedIdentityNavTarget(identityId: identityId) - }) - .environmentObject(platformState) - } - .navigationDestination(item: $navigateToCreatedIdentity) { target in - IdentityDetailView(identityId: target.identityId) + CreateIdentityView() + .environmentObject(platformState) } .sheet(isPresented: $showingSearchWallets) { SearchWalletsForIdentitiesView() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 3b63fa86bf2..cc022c3488b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -25,14 +25,6 @@ struct CreateIdentityView: View { @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState - /// Called when the user taps "View" on the success state. - /// Carries the newly created identity id back to the parent - /// (typically `IdentitiesContentView`), which is expected to - /// (a) dismiss this sheet and (b) push `IdentityDetailView` - /// in its own `NavigationStack`. Empty default makes the view - /// usable in places that don't need post-create navigation. - var onViewIdentity: (Data) -> Void = { _ in } - /// Default number of Platform identity authentication keys to /// register in this first-pass flow. First key is MASTER, the /// rest are HIGH. Advanced override is intentionally not exposed @@ -125,57 +117,41 @@ struct CreateIdentityView: View { /// the submit section swaps to a success banner and auto-dismiss. @State private var createdIdentityId: Data? = nil - /// Active registration controller for the Core-funded path - /// while the registration is in flight. Set in - /// `submitCoreFunded`; drives the pushed - /// `RegistrationProgressView` destination. Cleared on - /// terminal phase by `observeController`. + /// Active registration controller for the Core-funded path. + /// Stored only so `submitCoreFunded` has a local reference + /// after spawning it; the canonical lifetime owner is + /// `walletManager.registrationCoordinator`, which keeps the + /// controller available to the "Pending Registrations" row + /// on the Identities tab after this sheet dismisses. @State private var activeController: IdentityRegistrationController? = nil - /// Toggle for the pushed registration-progress destination. - /// `submit()` sets this to `true` once the coordinator has - /// spawned a controller; the dedicated screen then owns the - /// rest of the user flow (progress steps + success / error + - /// "View Identity" navigation). - @State private var showProgressDestination: Bool = false - var body: some View { NavigationStack { Form { - sourceWalletSection - fundingSection - amountSection - identityIndexSection - if canSubmit { - submitSection + if let controller = activeController { + // While registration is in flight (or has just + // reached a terminal phase), swap the entire + // form for the 5-step progress section + the + // terminal banner (success "Done" / failure + // error). The form's input sections would be + // noise at this point — the user has already + // committed; the controller lives on + // `walletManager.registrationCoordinator` and + // continues to run if this sheet is dismissed. + RegistrationProgressSection(controller: controller) + terminalSection(for: controller) + } else { + sourceWalletSection + fundingSection + amountSection + identityIndexSection + if canSubmit { + submitSection + } } } .navigationTitle("Create Identity") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(isPresented: $showProgressDestination) { - // Standalone progress screen for the spawned - // controller. Owns the 5-step progress UI and the - // terminal success / failure sections. The "View" - // button on success closes the sheet AND tells - // the parent which identity to navigate to via - // `onViewIdentity`. - if let controller = activeController { - RegistrationProgressView( - controller: controller, - onViewIdentity: { identityId in - // Tell the parent first (it stores - // the id and triggers its own - // navigation), then dismiss the - // sheet. SwiftUI processes the state - // update before the dismissal - // animation completes, so the parent - // is already primed to push. - onViewIdentity(identityId) - dismiss() - } - ) - } - } .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } @@ -192,6 +168,62 @@ struct CreateIdentityView: View { } } + /// Inline terminal banner that appears under the progress + /// section once the controller has reached `.completed` or + /// `.failed`. On success, shows the new identity id + a + /// "Done" button that dismisses the sheet (the new identity + /// appears in the Identities tab's `@Query` automatically). + /// On failure, shows the error message inline. + @ViewBuilder + private func terminalSection(for controller: IdentityRegistrationController) -> some View { + switch controller.phase { + case .completed(let identityId): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Identity created", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + Text(identityId.toBase58String()) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + .textSelection(.enabled) + Button { + // User acknowledged success — drop the + // controller from the coordinator's map so + // the "Pending Registrations" row on the + // Identities tab clears immediately + // (otherwise it lingers ~30 s until the + // post-completion retention sweep runs). + walletManager.registrationCoordinator.dismiss( + walletId: controller.walletId, + identityIndex: controller.identityIndex + ) + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + case .failed(let message): + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Registration failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + } + } + default: + EmptyView() + } + } + // MARK: - Sections private var sourceWalletSection: some View { @@ -668,13 +700,14 @@ struct CreateIdentityView: View { } ) - // Capture the controller for the pushed - // `RegistrationProgressView` destination, then trigger the - // navigation. The destination owns the progress UI + the - // success/failure terminal state from here on; this view - // becomes a no-op until the user pops back. + // Stash the controller; setting it flips the body to the + // inline progress + terminal section in place of the + // form. The controller's canonical lifetime owner is + // `walletManager.registrationCoordinator` — if the user + // dismisses the sheet mid-flight, the same controller is + // reachable via the "Pending Registrations" row on the + // Identities tab. self.activeController = controller - self.showProgressDestination = true // Observe phase transitions to mirror onto this view's // local success / error state. The controller stays in the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift index 36a97f01841..82790266aee 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift @@ -315,21 +315,11 @@ struct RegistrationProgressSection: View { /// `Form` should use `RegistrationProgressSection` directly. struct RegistrationProgressView: View { @ObservedObject var controller: IdentityRegistrationController + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var walletManager: PlatformWalletManager - /// Tap handler for the "View" button on success. Called with - /// the newly created identity id. The parent flow is expected - /// to (a) dismiss any presenting sheet and (b) navigate to - /// `IdentityDetailView` in its own NavigationStack. Empty - /// default for the standalone Pending-Registrations entry - /// point, where there's no sheet to dismiss. - var onViewIdentity: (Data) -> Void = { _ in } - - init( - controller: IdentityRegistrationController, - onViewIdentity: @escaping (Data) -> Void = { _ in } - ) { + init(controller: IdentityRegistrationController) { self.controller = controller - self.onViewIdentity = onViewIdentity } var body: some View { @@ -354,16 +344,19 @@ struct RegistrationProgressView: View { .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) .textSelection(.enabled) + // Dismisses the pushed progress view AND drops + // the controller from the coordinator so the + // "Pending Registrations" row on the Identities + // tab clears immediately (instead of lingering + // ~30 s for the retention sweep). Button { - // Close the create-identity sheet and let - // the parent push `IdentityDetailView` in - // its own NavigationStack. The handoff - // happens in `IdentitiesContentView` via - // a `(walletId, identityId)` state binding - // wired to a `.navigationDestination`. - onViewIdentity(identityId) + walletManager.registrationCoordinator.dismiss( + walletId: controller.walletId, + identityIndex: controller.identityIndex + ) + dismiss() } label: { - Text("View") + Text("Done") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index e5cf3b07100..7d39c0ad00c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1794,16 +1794,15 @@ struct WalletManagerMetadataStorageDetailView: View { struct AssetLockStorageDetailView: View { let record: PersistentAssetLock - /// Look up the operational identity at this asset lock's - /// `(walletId, identityIndex)` slot. Populated only for - /// IdentityRegistration / IdentityTopUp funding types — the - /// other funding types (invitation / asset-lock-address top-up) - /// don't bind to a specific identity slot on this wallet. - @Query private var linkedIdentities: [PersistentIdentity] + /// Candidate identity rows at this asset lock's + /// `identityIndex`. Filtered down to the strict + /// `(walletId, identityIndex)` match in `linkedIdentity` — + /// using the predicate alone would miss legacy rows that + /// don't yet have the `wallet` relationship populated. + @Query private var candidateIdentities: [PersistentIdentity] init(record: PersistentAssetLock) { self.record = record - let walletId = record.walletId // `PersistentAssetLock.identityIndexRaw` is `Int32` (the // changeset FFI uses i32 to match the upstream tracked // type), but `PersistentIdentity.identityIndex` is `UInt32` @@ -1812,14 +1811,29 @@ struct AssetLockStorageDetailView: View { // SwiftData's `#Predicate` macro doesn't allow inline // conversions inside the closure body. let identityIndex = UInt32(bitPattern: record.identityIndexRaw) - _linkedIdentities = Query( + _candidateIdentities = Query( filter: #Predicate { identity in - identity.wallet?.walletId == walletId - && identity.identityIndex == identityIndex + identity.identityIndex == identityIndex } ) } + /// Resolve the identity row this asset lock points at. Strict + /// `(walletId, identityIndex)` match preferred; legacy rows + /// that lack the `wallet` relationship fall back to a plain + /// `identityIndex` match (single candidate only — multiple + /// orphaned candidates at the same index are ambiguous and we + /// don't guess). + private var linkedIdentity: PersistentIdentity? { + if let strict = candidateIdentities.first(where: { + $0.wallet?.walletId == record.walletId + }) { + return strict + } + let orphaned = candidateIdentities.filter { $0.wallet == nil } + return orphaned.count == 1 ? orphaned.first : nil + } + var body: some View { Form { Section("Asset Lock") { @@ -1830,22 +1844,42 @@ struct AssetLockStorageDetailView: View { FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") FieldRow(label: "Wallet ID", value: hexString(record.walletId)) } - if isIdentityFunding, let identity = linkedIdentities.first { - // Bridge to the operational identity detail view - // from inside the storage page. Separate section - // so the row is visually clearly a navigation - // affordance — a single `NavigationLink` chevron, - // not mixed in with the plain `FieldRow` text rows - // above. + if isIdentityFunding { + // Identity section is always shown for identity- + // funding asset locks (Registration / TopUp). If the + // linked identity is in SwiftData, drill-down link; + // otherwise surface the current registration status + // so partial / in-flight asset locks aren't silently + // hidden. Section("Identity") { - NavigationLink { - IdentityDetailView(identityId: identity.identityId) - } label: { + if let identity = linkedIdentity { + // Static row — punted on navigation. Pushing + // `IdentityDetailView` from this nested + // Settings → Storage path hung the main + // thread on iOS 26 and burned a session + // chasing the cause. Tap-to-copy `Text` is + // good enough for an explorer surface; the + // operational identity view is reachable + // from the Identities tab. Text(identity.identityIdBase58) .font(.system(.caption, design: .monospaced)) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.middle) + .textSelection(.enabled) + } else { + // No matching identity row yet. Either the + // asset lock is still pre-finality + // (statusRaw 0/1) and the registration + // hasn't been submitted, or it's IS/CL- + // locked (2/3) but the IdentityCreate + // transition failed or wasn't submitted — + // surface either case with the current + // status so the entry is self-explanatory. + FieldRow( + label: pendingLabel(record.statusRaw), + value: statusLabel(record.statusRaw) + ) } } } @@ -1894,4 +1928,17 @@ struct AssetLockStorageDetailView: View { private var isIdentityFunding: Bool { record.fundingTypeRaw == 0 || record.fundingTypeRaw == 1 } + + /// Label for the pending row shown when no identity row has + /// been persisted for this slot yet. Communicates whether the + /// lock is mid-flight (still on its way to finality) versus + /// IS/CL-locked but the IdentityCreate transition never + /// completed. + private func pendingLabel(_ raw: Int) -> String { + switch raw { + case 0, 1: return "In progress" + case 2, 3: return "Pending (unused)" + default: return "Pending" + } + } } From 7c47f4055ae132392fbc02e8f52eab1bf701a134 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 14:35:40 +0700 Subject: [PATCH 15/54] feat(platform-wallet-ffi): resume identity registration from existing asset lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `platform_wallet_resume_identity_with_existing_asset_lock_signer` sibling to the wallet-balance funded variant. Takes an `OutPointFFI` instead of a duff amount and dispatches via `IdentityFunding::FromExistingAssetLock`, reusing the same `register_identity_with_funding` helper (so the resume / IS->CL fallback logic stays in one place on the Rust side). Extracts a private `decode_identity_pubkeys` helper shared by both funded-with-signer entry points; the only difference between fresh- build and resume paths is which `IdentityFunding` variant is constructed. Swift surface: `ManagedPlatformWallet.resumeIdentityWithAssetLock( outPointTxid:outPointVout:identityIndex:identityPubkeys:signer:)` mirrors `registerIdentityWithFunding`'s shape exactly — same `Task.detached` + `MnemonicResolver` lifecycle + `withExtendedLifetime` + `withPubkeyFFIArray` pattern. Caller passes the 32-byte raw txid (little-endian wire order, matching `OutPointFFI.txid`) and the vout; the wrapper packs them into the FFI struct. Iter 5 plumbing — the picker UI lands in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...dentity_registration_funded_with_signer.rs | 218 +++++++++++++++--- .../ManagedPlatformWallet.swift | 101 ++++++++ 2 files changed, 290 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index a635d7aae97..d03dba0a900 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::convert::TryFrom; use std::slice; +use dashcore::hashes::Hash; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; @@ -27,6 +28,7 @@ use platform_wallet::wallet::identity::types::funding::IdentityFunding; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; +use crate::core_wallet_types::OutPointFFI; use crate::derive_and_persist_callbacks::MnemonicResolverHandle; use crate::error::*; use crate::handle::*; @@ -35,6 +37,71 @@ use crate::mnemonic_resolver_core_signer::MnemonicResolverCoreSigner; use crate::runtime::block_on_worker; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// Decode the C-side `IdentityPubkeyFFI` rows into the +/// `BTreeMap` shape that +/// `IdentityWallet::register_identity_with_funding` expects. +/// +/// Shared by both the [`platform_wallet_register_identity_with_funding_signer`] +/// (fresh asset-lock build) and +/// [`platform_wallet_resume_identity_with_existing_asset_lock_signer`] +/// (resume from tracked outpoint) entry points — the two differ only in +/// how they construct the [`IdentityFunding`] variant; the keys-map +/// shape is identical. +/// +/// Returns `Err(PlatformWalletFFIResult)` carrying the FFI error the +/// caller should bubble up directly. Mirrors the inline `Err(...)` / +/// `unwrap_result_or_return!` flow elsewhere in this file. +unsafe fn decode_identity_pubkeys( + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, +) -> Result, PlatformWalletFFIResult> { + let pubkey_rows: &[IdentityPubkeyFFI] = + slice::from_raw_parts(identity_pubkeys, identity_pubkeys_count); + let mut keys_map: BTreeMap = BTreeMap::new(); + for (i, row) in pubkey_rows.iter().enumerate() { + let key_type = KeyType::try_from(row.key_type).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("identity_pubkeys[{i}].key_type invalid: {e}"), + ) + })?; + let purpose = Purpose::try_from(row.purpose).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("identity_pubkeys[{i}].purpose invalid: {e}"), + ) + })?; + let security_level = SecurityLevel::try_from(row.security_level).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("identity_pubkeys[{i}].security_level invalid: {e}"), + ) + })?; + if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + format!("identity_pubkeys[{i}].pubkey_bytes is null or empty"), + )); + } + let pubkey_bytes: Vec = + slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); + keys_map.insert( + row.key_id, + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds: None, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + ); + } + Ok(keys_map) +} + /// Register a new asset-lock-funded identity using an external signer. /// /// # Safety @@ -70,35 +137,10 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( ); } - let pubkey_rows: &[IdentityPubkeyFFI] = - slice::from_raw_parts(identity_pubkeys, identity_pubkeys_count); - let mut keys_map: BTreeMap = BTreeMap::new(); - for (i, row) in pubkey_rows.iter().enumerate() { - let key_type = unwrap_result_or_return!(KeyType::try_from(row.key_type)); - let purpose = unwrap_result_or_return!(Purpose::try_from(row.purpose)); - let security_level = unwrap_result_or_return!(SecurityLevel::try_from(row.security_level)); - if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorNullPointer, - format!("identity_pubkeys[{i}].pubkey_bytes is null or empty"), - ); - } - let pubkey_bytes: Vec = - slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); - keys_map.insert( - row.key_id, - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: row.key_id, - purpose, - security_level, - contract_bounds: None, - key_type, - read_only: row.read_only, - data: BinaryData::new(pubkey_bytes), - disabled_at: None, - }), - ); - } + let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { + Ok(m) => m, + Err(e) => return e, + }; // Round-trip both handles through `usize` so the spawned future's // capture is `Send + 'static` — same pattern used by the existing @@ -147,3 +189,121 @@ pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( *out_identity_handle = handle; PlatformWalletFFIResult::ok() } + +/// Resume identity registration from an already-tracked asset lock. +/// +/// Sister to [`platform_wallet_register_identity_with_funding_signer`]: +/// instead of building a fresh asset-lock transaction from wallet +/// balance, this entry point picks up an existing tracked lock by +/// outpoint and drives it through whatever stages remain (broadcast, +/// IS/CL wait, Platform submission). Use case is crash recovery — a +/// prior registration attempt left the lock in storage at +/// `InstantSendLocked` / `ChainLocked` but the IdentityCreate transition +/// never completed (app killed, network error, dismissed flow), and the +/// user now wants to consume the lock from the +/// "Fund from unused Asset Lock" picker in `CreateIdentityView`. +/// +/// The Rust side dispatches via [`IdentityFunding::FromExistingAssetLock`] +/// inside the same `register_identity_with_funding` helper used by the +/// wallet-balance path — the resume logic and IS→CL fallback live +/// there, not here. This FFI is a thin marshaler. +/// +/// # Safety +/// - `out_point` must be a valid, non-null pointer to an +/// `OutPointFFI` (32-byte raw txid + u32 vout). The caller retains +/// ownership; the FFI does not free it. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`. The caller retains +/// ownership. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// [`crate::dash_sdk_mnemonic_resolver_create`]. The caller retains +/// ownership. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_lock_signer( + wallet_handle: Handle, + out_point: *const OutPointFFI, + identity_index: u32, + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, + signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, + out_identity_id: *mut [u8; 32], + out_identity_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(out_point); + check_ptr!(signer_handle); + check_ptr!(core_signer_handle); + check_ptr!(identity_pubkeys); + check_ptr!(out_identity_id); + check_ptr!(out_identity_handle); + if identity_pubkeys_count == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "identity_pubkeys_count must be >= 1", + ); + } + + let out_point_ffi = *out_point; + let txid = match dashcore::Txid::from_slice(&out_point_ffi.txid) { + Ok(t) => t, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("out_point.txid is not a valid 32-byte txid: {e}"), + ); + } + }; + let resume_outpoint = dashcore::OutPoint { + txid, + vout: out_point_ffi.vout, + }; + + let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { + Ok(m) => m, + Err(e) => return e, + }; + + let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.sdk().network; + block_on_worker(async move { + // SAFETY: see the fn-level safety doc — both handles are + // pinned alive for the duration of this FFI call. + let identity_signer: &VTableSigner = + unsafe { &*(signer_addr as *const VTableSigner) }; + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + identity_wallet + .register_identity_with_funding( + IdentityFunding::FromExistingAssetLock { + out_point: resume_outpoint, + }, + identity_index, + keys_map, + identity_signer, + &asset_lock_signer, + None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let identity = unwrap_result_or_return!(result); + let id_bytes: [u8; 32] = identity.id().to_buffer(); + *out_identity_id = id_bytes; + let managed = platform_wallet::ManagedIdentity::new(identity, identity_index); + let handle = MANAGED_IDENTITY_STORAGE.insert(managed); + *out_identity_handle = handle; + PlatformWalletFFIResult::ok() +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index f250969613c..5f92f434174 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2420,4 +2420,105 @@ extension ManagedPlatformWallet { return (identityId, ManagedIdentity(handle: outManagedHandle)) }.value } + + /// Resume identity registration from an existing tracked asset lock. + /// + /// Sibling to + /// [`registerIdentityWithFunding(amountDuffs:identityIndex:identityPubkeys:signer:)`]: + /// the wallet-balance variant builds a fresh asset-lock transaction; + /// this variant picks up a lock that's already tracked (status + /// `InstantSendLocked` / `ChainLocked`) and drives whatever stages + /// remain. Use case is crash recovery — a prior attempt left the + /// lock in storage but the IdentityCreate transition never landed, + /// and the user picks the lock from the + /// "Fund from unused Asset Lock" surface in `CreateIdentityView`. + /// + /// `outPointTxid` is the 32-byte raw txid (little-endian wire order, + /// same shape as `OutPointFFI.txid` on the Rust side and what + /// `PersistentAssetLock.outPointHex` reverses for display); the + /// caller is responsible for decoding back from the display-order + /// hex before passing in. + /// + /// Caller MUST pre-derive `identityPubkeys` (typically via + /// `dash_sdk_derive_identity_keys_from_mnemonic`) AND pre-persist + /// each key's private material to the Keychain using + /// `prePersistIdentityKeysForRegistration` BEFORE calling this — + /// same precondition as `registerIdentityWithFunding`. + /// + /// Returns `(identityId, ManagedIdentity)` for the freshly + /// registered identity. + public func resumeIdentityWithAssetLock( + outPointTxid: Data, + outPointVout: UInt32, + identityIndex: UInt32, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + signer: KeychainSigner + ) async throws -> (Identifier, ManagedIdentity) { + guard outPointTxid.count == 32 else { + throw PlatformWalletError.invalidParameter( + "outPointTxid must be exactly 32 bytes (was \(outPointTxid.count))" + ) + } + guard !identityPubkeys.isEmpty else { + throw PlatformWalletError.invalidParameter("identityPubkeys is empty") + } + let handle = self.handle + let signerHandle = signer.handle + let pubkeys = identityPubkeys + // Same `MnemonicResolver` lifetime + vtable rationale as + // `registerIdentityWithFunding` — the credit-output private key + // is fetched per-call from Keychain, signed, zeroed; no priv + // key ever lives in Rust memory across operations. + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { + () -> (Identifier, ManagedIdentity) in + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + var outManagedHandle: Handle = NULL_HANDLE + var txidTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + outPointTxid.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &txidTuple) { dst in + dst.copyMemory(from: src) + } + } + var outPoint = OutPointFFI(txid: txidTuple, vout: outPointVout) + let pubkeyBuffers: [Data] = pubkeys.map { $0.pubkeyBytes } + let result = withExtendedLifetime((signer, coreSigner)) { + ManagedPlatformWallet.withPubkeyFFIArray( + pubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_resume_identity_with_existing_asset_lock_signer( + handle, + &outPoint, + identityIndex, + ffiRowsPtr, + UInt(ffiRowsCount), + signerHandle, + coreSigner.handle, + &idTuple, + &outManagedHandle + ) + } + } + try result.check() + let identityId = Swift.withUnsafeBytes(of: idTuple) { Data($0) } + return (identityId, ManagedIdentity(handle: outManagedHandle)) + }.value + } } From f31ee5d8426684ae7feba302c81a34c38bcbd148 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 14:40:38 +0700 Subject: [PATCH 16/54] feat(SwiftExampleApp): resume picker for unused asset locks (iter 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing `.unusedAssetLock` FundingSelection case to the `resumeIdentityWithAssetLock` FFI added in the previous commit. The form now: - Surfaces a list of tracked asset locks for the current wallet that are at status >= InstantSendLocked AND have no `PersistentIdentity` at the same `(walletId, identityIndex)`. The anti-join is post-fetch in Swift (SwiftData `#Predicate` can't express "no matching row in another model" cleanly). - Renders each row inline (Section + tappable Button rows) — no navigation push, no `.fullScreenCover` / `.navigationDestination` modifiers that broke `Picker` hit-testing on iOS 26 in earlier iters. - Pins the identity-registration index to the lock's slot when a row is picked; the `identityIndexSection` becomes read-only on this path so the user can confirm but not override. - Drops the amount section when resuming (the lock's funded amount is fixed). - Calls `walletManager.registrationCoordinator.startRegistration(...)` with a body that invokes `resumeIdentityWithAssetLock(outPointTxid:, outPointVout:, identityIndex:, identityPubkeys:, signer:)`. The existing 5-step progress UI binds to the same `PersistentAssetLock` row and reflects the resume (typically jumping from step 2 straight to step 5 since the lock is already final). Plan doc status line flipped to iter 1+2+3+4+5 done. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 2 +- .../Views/CreateIdentityView.swift | 363 ++++++++++++++++-- 2 files changed, 337 insertions(+), 28 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index 3acd9a5d535..94ed06a0ffc 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -1,6 +1,6 @@ # Create Identity from Core Funds — Plan (Draft 9) -Status: **iter 1 + 2 + 3 + 4 done. iter 5 (resume picker) still pending.** +Status: **iter 1 + 2 + 3 + 4 + 5 done.** Branch: `feat/swift/funding-with-asset-lock` Target: SwiftExampleApp, testnet validation first. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index cc022c3488b..4bd10ce2520 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -78,6 +78,14 @@ struct CreateIdentityView: View { /// Core-address `isUsed` flag). @Query private var allIdentities: [PersistentIdentity] + /// All tracked asset locks across wallets. Filtered down to + /// resumable rows (status >= `InstantSendLocked`, no matching + /// `PersistentIdentity` at the same `(walletId, identityIndexRaw)`) + /// inside `resumableAssetLocks(for:)` so the anti-join — which + /// SwiftData predicates can't express cross-model — stays in Swift. + @Query(sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)]) + private var allAssetLocks: [PersistentAssetLock] + // MARK: - Selection state /// The source wallet selection. `nil` encodes "pick nothing yet"; @@ -98,6 +106,12 @@ struct CreateIdentityView: View { /// logic (future) will detect + decode. @State private var walletlessProof: String = "" + /// Persistent-id of the asset-lock row the user picked under + /// `.unusedAssetLock`. Nil until they tap one. Identifying via + /// `PersistentIdentifier` (not `outPointHex`) keeps the picker + /// resilient if the underlying row updates its status mid-pick. + @State private var selectedAssetLockId: PersistentIdentifier? = nil + /// Amount (in DASH) to fund the new identity with. Populated /// automatically from the selected account's balance; the user /// can lower it but not exceed the available balance. @@ -143,6 +157,7 @@ struct CreateIdentityView: View { } else { sourceWalletSection fundingSection + assetLockPickerIfNeeded amountSection identityIndexSection if canSubmit { @@ -278,6 +293,17 @@ struct CreateIdentityView: View { } } + /// Resume picker shown only when the user picked + /// `.unusedAssetLock` against a wallet. Hidden otherwise so the + /// form stays compact for the common "fund from account" paths. + @ViewBuilder + private var assetLockPickerIfNeeded: some View { + if case .wallet(let walletId) = walletSelection, + fundingSelection == .unusedAssetLock { + assetLockPickerSection(for: walletId) + } + } + @ViewBuilder private func walletAccountSection(for walletId: Data) -> some View { let options = accountOptions(for: walletId) @@ -298,6 +324,11 @@ struct CreateIdentityView: View { // of the selected Platform Payment account so the // happy path is one tap. Users can dial it down. amountDash = defaultAmountString(for: newValue) + // Clear any prior asset-lock pick when the user + // switches away from `.unusedAssetLock` — a stale + // selection would otherwise stick around invisible + // and the submit gate would still accept it. + selectedAssetLockId = nil } } header: { Text("Funding Source") @@ -311,6 +342,105 @@ struct CreateIdentityView: View { } } + /// Picker for the `.unusedAssetLock` funding mode. Lists tracked + /// asset locks on the current wallet whose status is at least + /// `InstantSendLocked` (`statusRaw >= 2`) AND which have no + /// matching `PersistentIdentity` at the same + /// `(walletId, identityIndexRaw)`. The latter is the anti-join + /// the resume picker semantics call for — a tracked lock whose + /// identity has already registered is "consumed" even if the + /// row hasn't been purged yet. + /// + /// Tapping a row stores the row's `persistentModelID` and pushes + /// the lock's `identityIndexRaw` into the form's identity-index + /// state. The user can't re-pick the index — resume is bound to + /// the lock's original slot. + @ViewBuilder + private func assetLockPickerSection(for walletId: Data) -> some View { + let resumable = resumableAssetLocks(for: walletId) + if resumable.isEmpty { + Section { + Text("No resumable asset locks on this wallet.") + .font(.callout) + .foregroundColor(.secondary) + } header: { + Text("Unused Asset Locks") + } footer: { + Text( + "A resumable asset lock is one that's at InstantSend / " + + "ChainLock state but whose identity registration " + + "didn't complete. Build a fresh one via \"Fund from " + + "account\" instead." + ) + } + } else { + Section { + ForEach(resumable, id: \.persistentModelID) { lock in + assetLockRow(lock) + } + } header: { + Text("Unused Asset Locks") + } footer: { + Text( + "Pick a tracked asset lock to resume its identity " + + "registration. The lock's original identity-index " + + "slot is reused." + ) + } + } + } + + /// Single tappable row in the resume picker. Visually mirrors the + /// `Picker`-style selected checkmark used elsewhere in the form so + /// the interaction model reads the same way. + @ViewBuilder + private func assetLockRow(_ lock: PersistentAssetLock) -> some View { + let isSelected = selectedAssetLockId == lock.persistentModelID + Button { + selectedAssetLockId = lock.persistentModelID + // Pin the identity-index to the lock's slot — resume + // can't change the slot, it consumes the one the lock + // was built for. + identityIndex = UInt32(bitPattern: lock.identityIndexRaw) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(Self.formatDash( + raw: UInt64(bitPattern: Int64(lock.amountDuffs)), + divisor: Double(Self.duffsPerDash) + )) + .font(.body) + .foregroundColor(.primary) + Text("·") + .foregroundColor(.secondary) + Text("slot #\(UInt32(bitPattern: lock.identityIndexRaw))") + .font(.callout) + .foregroundColor(.secondary) + .monospacedDigit() + } + HStack(spacing: 6) { + Text(Self.assetLockStatusLabel(rawValue: lock.statusRaw)) + .font(.caption) + .foregroundColor(.secondary) + Text("·") + .foregroundColor(.secondary) + Text(Self.relativeDateString(lock.updatedAt)) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + /// Amount (in DASH) to fund the new identity with. Shown for /// Platform Payment and Core / CoinJoin funding sources. @ViewBuilder @@ -380,39 +510,67 @@ struct CreateIdentityView: View { // creations don't burn an identity-registration slot off our // HD tree. if case .wallet(let walletId) = walletSelection { - let used = usedIdentityIndices(for: walletId) - let collision = identityIndex.map { used.contains($0) } ?? false - Section { - Stepper(value: Binding( - get: { identityIndex ?? 0 }, - set: { identityIndex = $0 } - ), in: 0...UInt32.max) { + // Resume path: the lock fixes the slot. Render a read- + // only summary so the user can confirm which slot the + // tracked lock is anchored to, but the stepper is + // disabled. + if fundingSelection == .unusedAssetLock { + Section { HStack { Text("Identity Registration Index") Spacer() - Text("#\(identityIndex ?? 0)") - .foregroundColor(collision ? .red : .secondary) - .monospacedDigit() + if let identityIndex = identityIndex { + Text("#\(identityIndex)") + .foregroundColor(.secondary) + .monospacedDigit() + } else { + Text("—") + .foregroundColor(.secondary) + } } + } header: { + Text("Identity Registration Index") + } footer: { + Text( + "Resuming uses the asset lock's original slot — " + + "the index can't be overridden on this path." + ) } - if collision { + } else { + let used = usedIdentityIndices(for: walletId) + let collision = identityIndex.map { used.contains($0) } ?? false + Section { + Stepper(value: Binding( + get: { identityIndex ?? 0 }, + set: { identityIndex = $0 } + ), in: 0...UInt32.max) { + HStack { + Text("Identity Registration Index") + Spacer() + Text("#\(identityIndex ?? 0)") + .foregroundColor(collision ? .red : .secondary) + .monospacedDigit() + } + } + if collision { + Text( + "Index #\(identityIndex ?? 0) is already taken on " + + "this wallet. Pick a different index." + ) + .font(.caption) + .foregroundColor(.red) + } + } header: { + Text("Identity Registration Index") + } footer: { Text( - "Index #\(identityIndex ?? 0) is already taken on " - + "this wallet. Pick a different index." + "The DIP-9 identity-registration slot the new identity " + + "will consume " + + "(`m/9'/coin'/5'/0'/0'/N'/0'`). Defaults to one past " + + "the highest index already used on this wallet; " + + "override to pick any other unused index." ) - .font(.caption) - .foregroundColor(.red) } - } header: { - Text("Identity Registration Index") - } footer: { - Text( - "The DIP-9 identity-registration slot the new identity " - + "will consume " - + "(`m/9'/coin'/5'/0'/0'/N'/0'`). Defaults to one past " - + "the highest index already used on this wallet; " - + "override to pick any other unused index." - ) } } } @@ -467,6 +625,18 @@ struct CreateIdentityView: View { .isEmpty case (.wallet(let walletId), .some): guard let identityIndex = identityIndex else { return false } + // The resume path is bound to the lock's original + // identity-index slot. Selecting a lock pins + // `identityIndex` to its `identityIndexRaw`, so the + // collision check below would always trip when the + // lock's slot has been "taken" by the lock itself. + // Resume specifically needs the slot to NOT have a + // `PersistentIdentity` (the anti-join in + // `resumableAssetLocks`); the picker only surfaces + // rows that satisfy that, so we trust the row. + if fundingSelection == .unusedAssetLock { + return selectedAssetLockId != nil + } // Block submit on collision with an existing identity's // registration index. The picker shows a red collision // hint, but the button stays disabled regardless so the @@ -483,8 +653,6 @@ struct CreateIdentityView: View { let available = coreAccountBalanceDuffs(account) return duffs >= Self.minIdentityFundingDuffs && duffs <= available } - // Remaining wallet-backed paths (unused asset lock, - // future variants) are stubbed — submit stays disabled. return false default: return false @@ -578,11 +746,74 @@ struct CreateIdentityView: View { managedWallet: managedWallet, network: network ) + } else if fundingSelection == .unusedAssetLock, + let lock = selectedAssetLock { + submitResumed( + lock: lock, + walletId: walletId, + identityIndex: identityIndex, + identityPubkeys: identityPubkeys, + signer: signer, + managedWallet: managedWallet, + network: network + ) } else { submitError = .init(message: "Selected funding source is not yet supported.") } } + /// Resume registration against an existing tracked asset lock. + /// Same shape as `submitCoreFunded` — the only differences are + /// (a) the body closure invokes + /// `resumeIdentityWithAssetLock(...)` instead of + /// `registerIdentityWithFunding(amountDuffs:...)`, and (b) the + /// outpoint is decoded from the lock row's display-order + /// `outPointHex` to the raw 32-byte txid + u32 vout the FFI + /// wants. + private func submitResumed( + lock: PersistentAssetLock, + walletId: Data, + identityIndex: UInt32, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + signer: KeychainSigner, + managedWallet: ManagedPlatformWallet, + network: Network + ) { + guard let parts = Self.parseOutPointHex(lock.outPointHex) else { + submitError = .init( + message: "Tracked asset lock has a malformed outpoint and can't be resumed: \(lock.outPointHex)" + ) + return + } + let (txidRaw, vout) = parts + + isCreating = true + + let coordinator = walletManager.registrationCoordinator + let controller = coordinator.startRegistration( + walletId: walletId, + identityIndex: identityIndex, + body: { + let (identityId, _) = try await managedWallet.resumeIdentityWithAssetLock( + outPointTxid: txidRaw, + outPointVout: vout, + identityIndex: identityIndex, + identityPubkeys: identityPubkeys, + signer: signer + ) + return identityId + } + ) + + self.activeController = controller + observeController( + controller, + walletId: walletId, + identityIndex: identityIndex, + network: network + ) + } + /// Platform-Payment funded registration. Spends credits from /// `PersistentPlatformAddress` rows on the selected account. private func submitPlatformPayment( @@ -933,6 +1164,32 @@ struct CreateIdentityView: View { return account } + /// The tracked asset lock the user picked in the resume flow. + /// Looked up via `persistentModelID` so it stays valid even if + /// the row's mutable fields (status, updatedAt) tick after + /// selection. + private var selectedAssetLock: PersistentAssetLock? { + guard let id = selectedAssetLockId else { return nil } + return allAssetLocks.first { $0.persistentModelID == id } + } + + /// Resumable asset locks for the given wallet. Filters down to: + /// - `walletId == walletId` + /// - `statusRaw >= 2` (InstantSendLocked / ChainLocked) + /// - No matching `PersistentIdentity` at the same + /// `(walletId, identityIndex)` — the anti-join is post-fetch + /// in Swift because SwiftData `#Predicate` can't express + /// "no matching row in another model" cleanly. + private func resumableAssetLocks(for walletId: Data) -> [PersistentAssetLock] { + let usedIndices = usedIdentityIndices(for: walletId) + return allAssetLocks.filter { lock in + guard lock.walletId == walletId else { return false } + guard lock.statusRaw >= 2 else { return false } + let slot = UInt32(bitPattern: lock.identityIndexRaw) + return !usedIndices.contains(slot) + } + } + /// The currently-selected Core / CoinJoin account, if any. Used /// for the Core-funded identity-creation path (Standard BIP44/BIP32 /// or CoinJoin). The Rust function `create_funded_asset_lock_proof` @@ -1178,6 +1435,58 @@ struct CreateIdentityView: View { } } + /// Decode the display-order `outPointHex` (`:`) + /// stored on `PersistentAssetLock` back into `(rawTxidBytes, + /// vout)`. The rawTxidBytes are 32 bytes in wire / little- + /// endian order — what the FFI's `OutPointFFI.txid` field + /// expects. Reverse of `PersistentAssetLock.encodeOutPoint`. + /// Returns `nil` on any parse failure. + private static func parseOutPointHex(_ hex: String) -> (Data, UInt32)? { + let parts = hex.split( + separator: ":", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard parts.count == 2 else { return nil } + let txidHex = String(parts[0]) + guard let vout = UInt32(parts[1]) else { return nil } + guard txidHex.count == 64 else { return nil } + var txidDisplay = Data(capacity: 32) + var idx = txidHex.startIndex + for _ in 0..<32 { + let end = txidHex.index(idx, offsetBy: 2) + guard let byte = UInt8(txidHex[idx.. String { + switch rawValue { + case 0: return "Built" + case 1: return "Broadcast" + case 2: return "InstantSend locked" + case 3: return "ChainLock locked" + default: return "Unknown" + } + } + + /// Compact relative date string ("2 minutes ago"). Used by the + /// resume picker so the user can see how recent the lock is. + private static func relativeDateString(_ date: Date) -> String { + let fmt = RelativeDateTimeFormatter() + fmt.unitsStyle = .short + return fmt.localizedString(for: date, relativeTo: Date()) + } + /// `"0.01 DASH"` — stripped of trailing zeros, uses up to 8 decimals. private static func formatDash(raw: UInt64, divisor: Double) -> String { let dash = Double(raw) / divisor From be54cafbfafebe0cfd969c3b4464a75bb4a61e7d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 14:52:35 +0700 Subject: [PATCH 17/54] docs(swift-sdk): mark iter 6 done in identity-from-core-funds plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pieces were delivered as part of iter 3/4: AssetLockStorageListView / AssetLockStorageDetailView cover the storage-side drill-down, and WalletMemoryDetailView.trackedAssetLocksSection covers the per-wallet live FFI snapshot. No code changes required — only updating the plan doc status line and adding citations to the existing implementation sites. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index 94ed06a0ffc..31544ce2bf4 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -1,6 +1,6 @@ # Create Identity from Core Funds — Plan (Draft 9) -Status: **iter 1 + 2 + 3 + 4 + 5 done.** +Status: **iter 1 + 2 + 3 + 4 + 5 + 6 done.** Iter 7 (walletless paste flow) remains optional / out of scope unless requested. Branch: `feat/swift/funding-with-asset-lock` Target: SwiftExampleApp, testnet validation first. @@ -955,24 +955,28 @@ always set on a tracked lock.) --- -### Iter 6 — Explorer drill-downs +### Iter 6 — Explorer drill-downs ✅ **DONE (covered by iter 3/4 work)** **Goal**: full explorer visibility for tracked asset locks beyond the StorageExplorer row delivered in iter 2. **Steps**: -1. **`StorageExplorerView` detail view** for +1. ✅ **`StorageExplorerView` detail view** for `PersistentAssetLock`: list locks with `outPointHex`, `status`, `amountDuffs`, `identityIndexRaw`, `createdAt`, - `updatedAt`. SwiftData-backed. - -2. **`WalletMemoryExplorerView` drill-down**: expand the - existing "N asset locks" count (`:368`) into a sub-section - per wallet showing the live FFI snapshot - (`trackedAssetLocks(for: walletId)`). Follow the - `walletsSection` pattern at `:325`. FFI-backed (this view is - for *in-memory* wallet state, not SwiftData). + `updatedAt`. SwiftData-backed. — `AssetLockStorageListView` + at `StorageModelListViews.swift:1616` and + `AssetLockStorageDetailView` at + `StorageRecordDetailViews.swift:1794`. + +2. ✅ **`WalletMemoryExplorerView` drill-down**: per-wallet + live FFI snapshot of tracked asset locks via + `walletManager.trackedAssetLocks(for: walletId)`. — + `trackedAssetLocksSection` inside `WalletMemoryDetailView` + at `WalletMemoryExplorerView.swift:686`, reached by tapping + a wallet row in `walletsSection` (the row already surfaces + the `N asset locks` count via the in-memory summary). --- From f4ada01474e06ea076d1677f47d7a8f69e91509c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 15:29:46 +0700 Subject: [PATCH 18/54] test(SwiftExampleApp): pin resume-picker anti-join filter (iter 5) Extracts the resumable-asset-lock filter from CreateIdentityView into a pure static `resumableLocks(in:usedIndices:walletId:)` generic over a new `AssetLockResumeRow` protocol so unit tests can exercise the business logic without spinning up a SwiftData ModelContainer. View keeps its private `resumableAssetLocks(for:)` entry point as a one-line wrapper that supplies the live `@Query` results. Eight test cases cover the three pieces of logic that can silently regress: - walletId match (cross-wallet bleed) - statusRaw >= 2 floor (Built/Broadcast rejected, ISLock/CLock accepted, forward-compatible for any future status >= 2) - anti-join against the per-wallet used-slot set (including the Int32 -> UInt32 bitPattern bridge for the negative-index edge) Drive-by fix: KeyManagerTests:178 was calling `KeyFormatter.toWIF(_, isTestnet:)` but the SDK changed the signature to `network:` in #3050 (Feb 2026); the test target couldn't build. Updated the call so `xcodebuild test` works again. All 8 new tests pass on iPhone 17 Pro sim (iOS 26.4.1). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/CreateIdentityView.swift | 56 +++++- .../CreateIdentityResumableTests.swift | 170 ++++++++++++++++++ .../KeyManagerTests.swift | 2 +- 3 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 4bd10ce2520..3185a90c5c4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -19,6 +19,22 @@ import SwiftUI import SwiftDashSDK import SwiftData +/// Minimum surface of a tracked asset-lock row needed by the +/// resume-picker anti-join. Exists so the pure filter +/// `CreateIdentityView.resumableLocks(in:usedIndices:walletId:)` can +/// be unit-tested with lightweight structs instead of forcing tests +/// to spin up a SwiftData `ModelContainer` just to construct +/// `PersistentAssetLock` `@Model` instances. `PersistentAssetLock` +/// conforms automatically because it already exposes all three +/// properties on its public surface. +protocol AssetLockResumeRow { + var walletId: Data { get } + var statusRaw: Int { get } + var identityIndexRaw: Int32 { get } +} + +extension PersistentAssetLock: AssetLockResumeRow {} + struct CreateIdentityView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -1173,16 +1189,38 @@ struct CreateIdentityView: View { return allAssetLocks.first { $0.persistentModelID == id } } - /// Resumable asset locks for the given wallet. Filters down to: - /// - `walletId == walletId` - /// - `statusRaw >= 2` (InstantSendLocked / ChainLocked) - /// - No matching `PersistentIdentity` at the same - /// `(walletId, identityIndex)` — the anti-join is post-fetch - /// in Swift because SwiftData `#Predicate` can't express - /// "no matching row in another model" cleanly. + /// Resumable asset locks for the given wallet. View entry point — + /// wraps the pure `Self.resumableLocks(in:usedIndices:walletId:)` + /// with the live `@Query` results so the pure helper stays testable + /// without a SwiftData container. private func resumableAssetLocks(for walletId: Data) -> [PersistentAssetLock] { - let usedIndices = usedIdentityIndices(for: walletId) - return allAssetLocks.filter { lock in + Self.resumableLocks( + in: allAssetLocks, + usedIndices: usedIdentityIndices(for: walletId), + walletId: walletId + ) + } + + /// Pure anti-join: returns the subset of `locks` that are + /// resumable for `walletId`. A lock is resumable iff + /// - `walletId` matches, AND + /// - `statusRaw >= 2` (InstantSendLocked or ChainLocked — only + /// finalized locks can fund a Platform identity), AND + /// - the lock's `identityIndexRaw` slot is not in + /// `usedIndices` (i.e. no `PersistentIdentity` already lives + /// on this `(walletId, identityIndex)` pair). + /// + /// SwiftData `#Predicate` can't express "no matching row in + /// another model" cleanly, so this filter runs post-fetch. The + /// signature is intentionally generic over `AssetLockResumeRow` + /// so unit tests can feed it lightweight structs instead of + /// real `@Model` rows. + static func resumableLocks( + in locks: [R], + usedIndices: Set, + walletId: Data + ) -> [R] { + locks.filter { lock in guard lock.walletId == walletId else { return false } guard lock.statusRaw >= 2 else { return false } let slot = UInt32(bitPattern: lock.identityIndexRaw) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift new file mode 100644 index 00000000000..92a2402604d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import SwiftExampleApp + +/// Tests the pure anti-join that powers the "Fund from unused Asset +/// Lock" resume picker (iter 5). The filter has three pieces of +/// business logic that can silently regress: +/// +/// 1. `walletId` match — never surface a lock from a different +/// wallet (would otherwise sign with the wrong key path). +/// 2. `statusRaw >= 2` floor — only InstantSendLocked (2) or +/// ChainLocked (3) locks are resumable; Built (0) and +/// Broadcast (1) aren't final and the Platform side rejects +/// them. +/// 3. Anti-join on `(walletId, identityIndexRaw)` against the +/// set of in-use identity slots — never offer a lock whose +/// slot already has a `PersistentIdentity` row, otherwise the +/// resume submit would collide with an already-registered +/// identity. +/// +/// We exercise these with a lightweight `FakeAssetLockRow` so the +/// test stays a pure function call — no SwiftData container needed. +final class CreateIdentityResumableTests: XCTestCase { + + private struct FakeAssetLockRow: AssetLockResumeRow, Equatable { + let walletId: Data + let statusRaw: Int + let identityIndexRaw: Int32 + } + + private let walletA = Data(repeating: 0xA1, count: 8) + private let walletB = Data(repeating: 0xB2, count: 8) + + // MARK: - walletId match + + func testFiltersOutLocksFromOtherWallets() { + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), + FakeAssetLockRow(walletId: walletB, statusRaw: 2, identityIndexRaw: 0), + ] + let result = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [], + walletId: walletA + ) + XCTAssertEqual(result, [locks[0]]) + } + + // MARK: - statusRaw floor + + func testRejectsBuiltAndBroadcastStatuses() { + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 0, identityIndexRaw: 0), // Built + FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 1), // Broadcast + ] + let result = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [], + walletId: walletA + ) + XCTAssertTrue(result.isEmpty) + } + + func testAcceptsInstantSendLockedAndChainLocked() { + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), // ISLock + FakeAssetLockRow(walletId: walletA, statusRaw: 3, identityIndexRaw: 1), // CLock + ] + let result = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [], + walletId: walletA + ) + XCTAssertEqual(result.count, 2) + } + + /// Defensive: any unknown future status (e.g. 4) should still + /// pass the `>= 2` floor. If we ever flip from "≥2 means final" + /// to a closed set, this test will need updating — that's + /// intentional, the surprise is signal. + func testForwardCompatibleStatusFloor() { + let locks = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 4, identityIndexRaw: 0) + ] + let result = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [], + walletId: walletA + ) + XCTAssertEqual(result.count, 1) + } + + // MARK: - anti-join on used slots + + func testFiltersOutLocksWhoseSlotIsAlreadyUsed() { + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), + FakeAssetLockRow(walletId: walletA, statusRaw: 3, identityIndexRaw: 1), + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 2), + ] + let result = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [0, 2], + walletId: walletA + ) + XCTAssertEqual(result, [locks[1]]) // only slot 1 is unused + } + + /// The `usedIndices` set is per-wallet by construction (the + /// view derives it from `usedIdentityIndices(for: walletId)`), + /// so a slot used on wallet B must not bleed into wallet A's + /// pickability check. We model that by simply not including + /// wallet-B's slots in `usedIndices` when filtering for A. + func testUsedSlotsScopedPerWallet() { + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), + ] + // Pretend wallet B has slot 0 used, but the caller — which + // is filtering for wallet A — never tells us that. Lock on + // wallet A at slot 0 must stay resumable. + let result = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [], + walletId: walletA + ) + XCTAssertEqual(result, locks) + } + + /// `identityIndexRaw` is `Int32` (the storage row type) but the + /// in-use index set is `Set` (the FFI / wallet-side + /// type). The filter bridges via `UInt32(bitPattern:)`. A + /// negative `Int32` therefore maps to a high `UInt32`. This + /// test pins that conversion so a future cast change (e.g. + /// `UInt32(lockIdentityIndexRaw)` which would trap on negative) + /// fails loudly here instead of crashing in production. + func testNegativeIdentityIndexBridgesViaBitPattern() { + let negativeIndex: Int32 = -1 + let bridged = UInt32(bitPattern: negativeIndex) // 0xFFFF_FFFF + + // Lock with slot -1 / 0xFFFF_FFFF — when that exact bridged + // value is in `usedIndices`, the lock must be filtered out. + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: negativeIndex) + ] + let blocked = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [bridged], + walletId: walletA + ) + XCTAssertTrue(blocked.isEmpty) + + // ...and when it's NOT in `usedIndices`, the lock stays. + let kept = CreateIdentityView.resumableLocks( + in: locks, + usedIndices: [], + walletId: walletA + ) + XCTAssertEqual(kept, locks) + } + + // MARK: - empty inputs + + func testEmptyLocksListReturnsEmpty() { + let result = CreateIdentityView.resumableLocks( + in: [FakeAssetLockRow](), + usedIndices: [0, 1, 2], + walletId: walletA + ) + XCTAssertTrue(result.isEmpty) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift index fff8bd68551..144f300b914 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift @@ -175,7 +175,7 @@ final class KeyManagerTests: XCTestCase { ]) // Encode to WIF - guard let wif = KeyFormatter.toWIF(originalKey, isTestnet: true) else { + guard let wif = KeyFormatter.toWIF(originalKey, network: .testnet) else { XCTFail("Failed to encode to WIF") return } From 798eecf6e89c1c3980718024ee77fc3f7fcf730c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 15:38:25 +0700 Subject: [PATCH 19/54] feat(SwiftExampleApp): surface orphan asset locks as resumable registrations When the user kills the app mid-registration after the asset lock finalizes but before identity registration completes, the Pending Registrations row (driven by the in-memory RegistrationCoordinator) is wiped on restart. The orphan lock still lives in SwiftData but the user has no surface signal to find it. Adds a SwiftData-backed "Resumable Registrations" section to the Identities tab that auto-surfaces every PersistentAssetLock at statusRaw >= 2 with no matching PersistentIdentity at the same (walletId, identityIndex) slot. Tapping Resume opens CreateIdentityView pre-configured for the .unusedAssetLock funding path with that specific lock pinned. Re-uses the resumableLocks(...) pure filter extracted in f4ada01474 and generalizes the per-wallet used-slot set across all wallets. Two new unit tests pin the cross-wallet form of the anti-join. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 224 ++++++++++++++++++ .../Views/CreateIdentityView.swift | 38 +++ .../CreateIdentityResumableTests.swift | 78 ++++++ 3 files changed, 340 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 36a027b4995..9cf1757171b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -15,6 +15,19 @@ struct IdentitiesContentView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \PersistentIdentity.identityIndex) private var identities: [PersistentIdentity] + /// All tracked asset locks across wallets. Filtered into + /// "resumable" rows (status >= `InstantSendLocked` AND no + /// `PersistentIdentity` at the same `(walletId, identityIndex)` + /// slot) by `resumableAssetLocks` so the orphan-lock-after-crash + /// case surfaces as a tappable Resume row. Sorted newest-first + /// by `updatedAt` so the most recent unfinished registration + /// sits at the top of the section. + @Query(sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)]) + private var allAssetLocks: [PersistentAssetLock] + /// All wallets, used purely for the "wallet name" lookup on the + /// Resume row label. Cheap query — wallet rows are few and + /// the matching is on the in-memory array. + @Query private var allWallets: [PersistentWallet] @State private var showingLoadIdentity = false @State private var showingCreateIdentity = false @State private var showingSearchWallets = false @@ -23,10 +36,15 @@ struct IdentitiesContentView: View { /// an id so the dialog can show the display name / truncated id /// without a second fetch. @State private var identityPendingRemoval: PersistentIdentity? + /// Asset lock the user tapped Resume on. Drives the `.sheet(item:)` + /// presentation of a pre-configured `CreateIdentityView`. Cleared + /// when the sheet dismisses (SwiftUI nils the binding for us). + @State private var resumingAssetLock: PersistentAssetLock? var body: some View { List { pendingRegistrationsSection + resumableRegistrationsSection if identities.isEmpty { Section { VStack(spacing: 12) { @@ -151,6 +169,16 @@ struct IdentitiesContentView: View { CreateIdentityView() .environmentObject(platformState) } + .sheet(item: $resumingAssetLock) { lock in + // Pre-configured resume of an orphan asset lock. Same + // view, same code path — the constructor seeds the + // four selection `@State`s as initial values so the + // form opens already on the "Fund from unused Asset + // Lock" step with this specific lock highlighted, and + // the user only has to tap "Create Identity". + CreateIdentityView(preselectedAssetLock: lock) + .environmentObject(platformState) + } .sheet(isPresented: $showingSearchWallets) { SearchWalletsForIdentitiesView() } @@ -183,6 +211,114 @@ struct IdentitiesContentView: View { ) } + /// "Resumable Registrations" row group. Surfaces orphan + /// `PersistentAssetLock` rows — those at status >= + /// `InstantSendLocked` (`statusRaw >= 2`) with no + /// `PersistentIdentity` at the same `(walletId, identityIndex)` + /// slot — that the in-memory `RegistrationCoordinator` can't + /// know about because its map is wiped on app restart. Without + /// this section, an app kill mid-registration leaves the user + /// with no surface signal that there's an orphan lock waiting + /// to be resumed: the lock still lives in SwiftData, but the + /// "Pending Registrations" section above only reflects the + /// in-memory coordinator state. Tapping Resume opens + /// `CreateIdentityView` pre-configured for the `.unusedAssetLock` + /// funding path with this specific lock pinned. + /// + /// Empty when there are no orphan locks; collapses to nothing + /// in that case so the rest of the screen isn't pushed down by + /// an empty header. + @ViewBuilder + private var resumableRegistrationsSection: some View { + let locks = resumableAssetLocks + if !locks.isEmpty { + Section("Resumable Registrations (\(locks.count))") { + ForEach(locks) { lock in + ResumableRegistrationRow( + lock: lock, + walletLabel: walletDisplayLabel(for: lock.walletId), + onResume: { + resumingAssetLock = lock + } + ) + } + } + } + } + + /// Cross-wallet variant of the per-wallet resume picker filter + /// implemented at `CreateIdentityView.resumableLocks(...)`. Same + /// anti-join logic, but the per-wallet `usedIndices` set is + /// generalized to a per-`(walletId, identityIndex)` set so we + /// can filter all wallets in one pass. + /// + /// Independent of `RegistrationCoordinator` — this is purely a + /// SwiftData read. Survives app restarts because the underlying + /// `PersistentAssetLock` and `PersistentIdentity` rows are + /// disk-persisted. + private var resumableAssetLocks: [PersistentAssetLock] { + let usedSlots: Set = Set( + identities.compactMap { identity -> UsedSlot? in + guard let walletId = identity.wallet?.walletId else { + return nil + } + return UsedSlot(walletId: walletId, slot: identity.identityIndex) + } + ) + return Self.crossWalletResumableLocks( + in: allAssetLocks, + usedSlots: usedSlots + ) + } + + /// Wallet display label for the Resume row's sub-line. Prefers + /// the wallet's stored name (via `PersistentWallet.label`'s + /// short-hex fallback), so the row shows "MyWallet" / "Wallet + /// a1b2c3d4…" rather than a raw 32-byte id dump. + private func walletDisplayLabel(for walletId: Data) -> String { + if let wallet = allWallets.first(where: { $0.walletId == walletId }) { + return wallet.label + } + // Defensive — should never hit this branch in practice + // because every asset lock is owned by a wallet that's + // in `allWallets`. Mirrors the same fallback shape that + // `PersistentWallet.label` uses so the cosmetic doesn't + // diverge. + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } + + /// Pure anti-join across all wallets. A lock is resumable iff + /// - `statusRaw >= 2` (InstantSendLocked or ChainLocked), AND + /// - no `(walletId, identityIndex)` slot is already claimed by + /// a `PersistentIdentity` row. + /// + /// Generic over `AssetLockResumeRow` (the same protocol the + /// per-wallet helper uses) so the pure filter is unit-testable + /// without spinning up a SwiftData container. + static func crossWalletResumableLocks( + in locks: [R], + usedSlots: Set + ) -> [R] { + locks.filter { lock in + guard lock.statusRaw >= 2 else { return false } + let slot = UInt32(bitPattern: lock.identityIndexRaw) + return !usedSlots.contains( + UsedSlot(walletId: lock.walletId, slot: slot) + ) + } + } + + /// Composite key for the per-`(walletId, identityIndex)` + /// anti-join. Public visibility so unit tests in the same + /// module can build the set directly. + struct UsedSlot: Hashable { + let walletId: Data + let slot: UInt32 + } + /// Short title for the removal confirmation dialog. Uses /// `displayName` so the user sees the DPNS name / alias when /// available, and a truncated-hex id otherwise — matches the @@ -253,3 +389,91 @@ struct IdentitiesContentView: View { } } } + +/// Single row in the "Resumable Registrations" section. Renders the +/// lock summary (txid prefix, amount, status, owning wallet, slot) +/// plus a compact Resume button that fires the caller-supplied +/// `onResume` closure. Visually matches the row density of +/// `IdentityRow` and the storage-explorer's `AssetLockStorageListView` +/// row at `StorageModelListViews.swift:1636`. +private struct ResumableRegistrationRow: View { + let lock: PersistentAssetLock + let walletLabel: String + let onResume: () -> Void + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Asset Lock \(shortOutPoint(lock.outPointHex))") + .font(.body) + .lineLimit(1) + HStack(spacing: 6) { + Text(formatDuffs(lock.amountDuffs)) + .font(.caption) + .foregroundColor(.secondary) + Text("·") + .font(.caption) + .foregroundColor(.secondary) + Text(statusLabel(lock.statusRaw)) + .font(.caption) + .foregroundColor(.secondary) + Text("·") + .font(.caption) + .foregroundColor(.secondary) + Text("#\(UInt32(bitPattern: lock.identityIndexRaw))") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + Text(walletLabel) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button(action: onResume) { + Label("Resume", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + .font(.callout) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + // Buttons embedded in `List` rows can have their hit + // test stolen by SwiftUI's row tap surface, which on + // some iOS versions ends up firing for every row. + // `.buttonStyle(.borderedProminent)` already binds the + // tap to this Button, but adding an explicit hit shape + // keeps the target tight on the Resume label only. + } + .padding(.vertical, 2) + } + + /// Short txid prefix (first 8 hex chars) from the canonical + /// `:` outpoint encoding. Matches the row format + /// used by `AssetLockStorageListView`. + private func shortOutPoint(_ outPointHex: String) -> String { + let parts = outPointHex.split( + separator: ":", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard parts.count == 2 else { return outPointHex } + let txidPrefix = parts[0].prefix(8) + return "\(txidPrefix):\(parts[1])" + } + + private func formatDuffs(_ amountDuffs: Int64) -> String { + let dash = Double(amountDuffs) / 1e8 + return String(format: "%g DASH", dash) + } + + private func statusLabel(_ raw: Int) -> String { + switch raw { + case 0: return "Built" + case 1: return "Broadcast" + case 2: return "InstantSendLocked" + case 3: return "ChainLocked" + default: return "Unknown(\(raw))" + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 3185a90c5c4..061b175fad1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -155,6 +155,44 @@ struct CreateIdentityView: View { /// on the Identities tab after this sheet dismisses. @State private var activeController: IdentityRegistrationController? = nil + /// Optional pre-pick wired up by the Identities-tab "Resumable + /// Registrations" section. When non-nil, the four selection + /// `@State`s below are seeded in `init` so the form opens + /// already-configured for the resume path — same final state + /// the user would land on by hand-picking the wallet, choosing + /// "Fund from unused Asset Lock", and tapping the lock in the + /// picker. Seeding in `init` (via `_state = State(initialValue: + /// …)`) — rather than mutating in `.onAppear` / `.task` — + /// matters because the form's `.onChange(of: walletSelection)` + /// handler resets `fundingSelection` and `identityIndex` on any + /// runtime mutation; treating the preselect as the *initial* + /// state instead never trips that reset cascade. + /// Default `nil` keeps the original "Identities tab → + → + /// Create Identity" entry point unchanged. + let preselectedAssetLock: PersistentAssetLock? + + init(preselectedAssetLock: PersistentAssetLock? = nil) { + self.preselectedAssetLock = preselectedAssetLock + if let lock = preselectedAssetLock { + // Explicit `Optional(…)` wraps so the `@State` initial + // values match the underlying Optional types — Swift + // can't infer a `T?` from a bare `.wallet(...)` literal + // here because the inferred type lands on `T`. + _walletSelection = State( + initialValue: Optional(WalletSelection.wallet(id: lock.walletId)) + ) + _fundingSelection = State( + initialValue: Optional(FundingSelection.unusedAssetLock) + ) + _selectedAssetLockId = State( + initialValue: Optional(lock.persistentModelID) + ) + _identityIndex = State( + initialValue: Optional(UInt32(bitPattern: lock.identityIndexRaw)) + ) + } + } + var body: some View { NavigationStack { Form { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index 92a2402604d..69426f453ce 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -167,4 +167,82 @@ final class CreateIdentityResumableTests: XCTestCase { ) XCTAssertTrue(result.isEmpty) } + + // MARK: - cross-wallet anti-join (Identities-tab surface) + + /// `IdentitiesContentView.resumableRegistrationsSection` is the + /// surface that catches orphan locks after an app crash — the + /// in-memory `RegistrationCoordinator` map is wiped on restart, + /// so this section is the user's only signal that an asset + /// lock at `InstantSendLocked` / `ChainLocked` is waiting to be + /// resumed. It uses `crossWalletResumableLocks(in:usedSlots:)` + /// instead of the per-wallet helper because the section spans + /// every wallet in one pass. + /// + /// The pair of tests below pin the two pieces of business + /// logic that differ from the per-wallet form: + /// + /// 1. The `usedSlots` set is keyed by + /// `(walletId, identityIndex)` — a slot used on wallet A + /// must not block the same numerical slot on wallet B. + /// 2. The cross-wallet pass still enforces the `statusRaw >= + /// 2` floor — Built / Broadcast locks aren't resumable + /// regardless of which wallet they live on. + func testCrossWalletFilterDoesNotBleedSlotsAcrossWallets() { + // Same numerical slot (0) on both wallets, but only + // wallet-A's slot is taken by an existing identity. The + // lock on wallet B at slot 0 must stay resumable — the + // user's identity on A has no bearing on B's slot pool. + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), + FakeAssetLockRow(walletId: walletB, statusRaw: 2, identityIndexRaw: 0), + ] + let usedSlots: Set = [ + IdentitiesContentView.UsedSlot(walletId: walletA, slot: 0) + ] + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: usedSlots + ) + // Only wallet B's lock survives. + XCTAssertEqual(result, [locks[1]]) + } + + func testCrossWalletFilterEnforcesStatusFloor() { + // Two locks on two different wallets, both at slot 0, but + // wallet A's is `Broadcast` (statusRaw 1, pre-final) and + // wallet B's is `ChainLocked` (statusRaw 3, final). Only + // the ChainLocked lock should pass the filter — Broadcast + // can't fund a Platform identity yet. + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0), + FakeAssetLockRow(walletId: walletB, statusRaw: 3, identityIndexRaw: 0), + ] + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: [] + ) + XCTAssertEqual(result, [locks[1]]) + } + + /// Edge case: a lock at a slot that's marked used on its OWN + /// wallet must still be filtered out. This is the orphan- + /// recovery semantics in reverse — if the registration has + /// completed (a `PersistentIdentity` row exists at the same + /// `(walletId, identityIndex)`), the lock is no longer + /// "resumable", it's consumed. Even if its + /// `PersistentAssetLock` row hasn't been purged yet. + func testCrossWalletFilterFiltersOutOwnWalletUsedSlot() { + let locks: [FakeAssetLockRow] = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 7) + ] + let usedSlots: Set = [ + IdentitiesContentView.UsedSlot(walletId: walletA, slot: 7) + ] + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: usedSlots + ) + XCTAssertTrue(result.isEmpty) + } } From 257ba03c3e649cc827d07ed70d3cefac8838c5fe Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 15:56:02 +0700 Subject: [PATCH 20/54] feat(SwiftExampleApp): surface in-flight asset locks (status 1) on Identities tab After a crash mid-registration, the user previously only saw the orphan lock once SPV delivered the InstantSendLock and the persister flipped statusRaw from 1 -> 2. Until that moment (seconds-to-minutes on testnet) the Resumable Registrations section was empty and the user had no signal that anything was in flight. Lowers the cross-wallet visibility floor from statusRaw >= 2 to statusRaw >= 1 (Broadcast). The row's trailing affordance now stages on status: - 1 Broadcast: spinner + "Waiting for InstantSendLock..." - 2 InstantSendLocked / 3 ChainLocked: Resume button SwiftData @Query is reactive, so when SPV delivers the IS lock and the persister updates the row to (2) the trailing view re-renders into the Resume button automatically. The per-wallet picker in CreateIdentityView keeps its stricter statusRaw >= 2 floor: a Resume button must be tappable, and a Broadcast lock has no usable proof to fund Platform yet. The asymmetry is pinned by a new regression test (testPickerFloorStaysStricterThanSectionFloor) so a future "unify the floors" refactor fails loudly. Tests: 13/13 green (was 11/11), 2 new cases pin Broadcast visibility and the two-floor invariant; the existing testCrossWalletFilterEnforcesStatusFloor was updated to assert the new floor (Built rejected, Broadcast accepted). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 94 ++++++++++++++----- .../CreateIdentityResumableTests.swift | 65 +++++++++++-- 2 files changed, 129 insertions(+), 30 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 9cf1757171b..cb2d80e6aa9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -212,18 +212,28 @@ struct IdentitiesContentView: View { } /// "Resumable Registrations" row group. Surfaces orphan - /// `PersistentAssetLock` rows — those at status >= - /// `InstantSendLocked` (`statusRaw >= 2`) with no - /// `PersistentIdentity` at the same `(walletId, identityIndex)` - /// slot — that the in-memory `RegistrationCoordinator` can't - /// know about because its map is wiped on app restart. Without - /// this section, an app kill mid-registration leaves the user - /// with no surface signal that there's an orphan lock waiting - /// to be resumed: the lock still lives in SwiftData, but the - /// "Pending Registrations" section above only reflects the - /// in-memory coordinator state. Tapping Resume opens - /// `CreateIdentityView` pre-configured for the `.unusedAssetLock` - /// funding path with this specific lock pinned. + /// `PersistentAssetLock` rows — those at status >= `Broadcast` + /// (`statusRaw >= 1`) with no `PersistentIdentity` at the same + /// `(walletId, identityIndex)` slot — that the in-memory + /// `RegistrationCoordinator` can't know about because its map + /// is wiped on app restart. Without this section, an app kill + /// mid-registration leaves the user with no surface signal + /// that there's an in-flight lock: the lock still lives in + /// SwiftData, but the "Pending Registrations" section above + /// only reflects the in-memory coordinator state. + /// + /// Each row's trailing affordance is staged on the lock's + /// `statusRaw`: + /// - `1` Broadcast: spinner + "Waiting for InstantSendLock…" + /// — the lock can't fund a Platform identity until the + /// masternodes sign an IS lock. SPV is running; the + /// persister will flip the row to (2) when the event + /// arrives, and SwiftData `@Query` re-renders the row + /// into the actionable state without any extra wiring. + /// - `2` / `3` InstantSendLocked / ChainLocked: Resume + /// button. Tapping opens `CreateIdentityView` pre-configured + /// for the `.unusedAssetLock` funding path with this lock + /// pinned. /// /// Empty when there are no orphan locks; collapses to nothing /// in that case so the rest of the screen isn't pushed down by @@ -290,11 +300,35 @@ struct IdentitiesContentView: View { return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" } - /// Pure anti-join across all wallets. A lock is resumable iff - /// - `statusRaw >= 2` (InstantSendLocked or ChainLocked), AND + /// Pure anti-join across all wallets. A lock is *visible* on the + /// Resumable Registrations surface iff + /// - `statusRaw >= 1` (Broadcast or higher), AND /// - no `(walletId, identityIndex)` slot is already claimed by /// a `PersistentIdentity` row. /// + /// The floor here (`>= 1`, Broadcast) is intentionally **lower + /// than the per-wallet picker's floor** in + /// `CreateIdentityView.resumableLocks(...)` (which uses `>= 2`, + /// InstantSendLocked). Reason: the picker only surfaces locks + /// that can fund a Platform identity *right now* — only IS- + /// or chain-locked locks have a usable proof — so its row is + /// always immediately actionable. This section, by contrast, + /// is the user's only signal that *any* registration is + /// mid-flight after an app restart. A lock at Broadcast (1) is + /// in mid-handoff — SPV will deliver the InstantSendLock + /// shortly and the persister will flip it to (2), at which + /// point the row's trailing affordance flips from a spinner to + /// a Resume button automatically (SwiftData `@Query` is + /// reactive). Hiding (1) entirely would create the UX + /// asymmetry where users see their just-broadcast lock at + /// (1) vanish from the UI, then reappear seconds later at + /// (2) — confusing rather than reassuring. + /// + /// `statusRaw == 0` (Built but never broadcast) is still + /// filtered out: it's a tight crash window between TX build + /// and broadcast, and there's no useful UX action to take. + /// A re-broadcast would have to come from a different surface. + /// /// Generic over `AssetLockResumeRow` (the same protocol the /// per-wallet helper uses) so the pure filter is unit-testable /// without spinning up a SwiftData container. @@ -303,7 +337,7 @@ struct IdentitiesContentView: View { usedSlots: Set ) -> [R] { locks.filter { lock in - guard lock.statusRaw >= 2 else { return false } + guard lock.statusRaw >= 1 else { return false } let slot = UInt32(bitPattern: lock.identityIndexRaw) return !usedSlots.contains( UsedSlot(walletId: lock.walletId, slot: slot) @@ -431,6 +465,21 @@ private struct ResumableRegistrationRow: View { .lineLimit(1) } Spacer(minLength: 8) + trailingAffordance + } + .padding(.vertical, 2) + } + + /// Trailing view that depends on the lock's stage. At + /// `Broadcast` (1) the lock isn't usable yet — SPV is waiting + /// on the masternodes to sign an InstantSendLock — so we show + /// a spinner instead of a button. SwiftData `@Query` is + /// reactive, so when the persister flips the row to + /// `InstantSendLocked` (2) this view re-renders into the + /// Resume button without any extra plumbing. + @ViewBuilder + private var trailingAffordance: some View { + if lock.statusRaw >= 2 { Button(action: onResume) { Label("Resume", systemImage: "arrow.clockwise") .labelStyle(.titleAndIcon) @@ -438,14 +487,15 @@ private struct ResumableRegistrationRow: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - // Buttons embedded in `List` rows can have their hit - // test stolen by SwiftUI's row tap surface, which on - // some iOS versions ends up firing for every row. - // `.buttonStyle(.borderedProminent)` already binds the - // tap to this Button, but adding an explicit hit shape - // keeps the target tight on the Resume label only. + } else { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Waiting for InstantSendLock…") + .font(.caption) + .foregroundColor(.secondary) + } } - .padding(.vertical, 2) } /// Short txid prefix (first 8 hex chars) from the canonical diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index 69426f453ce..eee71229db5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -209,20 +209,69 @@ final class CreateIdentityResumableTests: XCTestCase { } func testCrossWalletFilterEnforcesStatusFloor() { - // Two locks on two different wallets, both at slot 0, but - // wallet A's is `Broadcast` (statusRaw 1, pre-final) and - // wallet B's is `ChainLocked` (statusRaw 3, final). Only - // the ChainLocked lock should pass the filter — Broadcast - // can't fund a Platform identity yet. + // The Resumable Registrations section uses a `>= 1` + // (Broadcast) floor — strictly lower than the per-wallet + // picker's `>= 2` floor. Reason: the section is the user's + // only post-restart signal that *any* registration is in + // flight, including pre-IS-lock locks that aren't + // tappable yet. Built (0) is still rejected — that's a + // tight crash window with no useful UX recovery action. let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0), - FakeAssetLockRow(walletId: walletB, statusRaw: 3, identityIndexRaw: 0), + FakeAssetLockRow(walletId: walletA, statusRaw: 0, identityIndexRaw: 0), // Built — rejected + FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 1), // Broadcast — accepted + FakeAssetLockRow(walletId: walletB, statusRaw: 3, identityIndexRaw: 0), // ChainLocked — accepted ] let result = IdentitiesContentView.crossWalletResumableLocks( in: locks, usedSlots: [] ) - XCTAssertEqual(result, [locks[1]]) + XCTAssertEqual(result, [locks[1], locks[2]]) + } + + /// The whole point of dropping the cross-wallet floor from + /// `>= 2` to `>= 1`: a lock at `Broadcast` (1) must surface + /// on the Identities tab so a user who killed the app between + /// "broadcast TX" and "wait for IS lock" sees their in-flight + /// registration. Without this, the row would silently vanish + /// until SPV delivered the IS lock and the persister flipped + /// the row to (2) — confusing rather than reassuring. + func testCrossWalletFilterIncludesBroadcastForVisibility() { + let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: [] + ) + XCTAssertEqual(result, [lock]) + } + + /// The per-wallet picker (`CreateIdentityView.resumableLocks`) + /// has a *different*, stricter floor: `>= 2`. It only surfaces + /// locks that can fund a Platform identity right now — + /// Broadcast locks have no usable proof yet. This test pins + /// the asymmetry so a future "unify the floors" refactor + /// fails loudly here. + func testPickerFloorStaysStricterThanSectionFloor() { + let broadcast = FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0) + + let pickerResult = CreateIdentityView.resumableLocks( + in: [broadcast], + usedIndices: [], + walletId: walletA + ) + XCTAssertTrue( + pickerResult.isEmpty, + "Per-wallet picker must reject Broadcast (1)" + ) + + let sectionResult = IdentitiesContentView.crossWalletResumableLocks( + in: [broadcast], + usedSlots: [] + ) + XCTAssertEqual( + sectionResult, + [broadcast], + "Resumable Registrations section must accept Broadcast (1)" + ) } /// Edge case: a lock at a slot that's marked used on its OWN From f466b7c4c771b209812444ed99be9e7c83325078 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 16:47:41 +0700 Subject: [PATCH 21/54] refactor(SwiftExampleApp): drop in-form resume picker; resume lives on Identities tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path A — the ".unusedAssetLock" funding-source option in the Create Identity sheet — is now redundant. The Identities-tab "Resumable Registrations" section (Path B) surfaces every orphan asset lock with a Resume button that pre-fills CreateIdentityView, so a duplicate in-form picker was extra taps for the same outcome plus a misleading "Funding source" framing (resuming an existing lock isn't funding — it's resumption). Changes: - CreateIdentityView's funding-source picker drops the "Fund from unused Asset Lock" option; the footer points to the Identities tab. - The sub-picker (assetLockPickerSection / assetLockRow), the per-wallet resumableAssetLocks(for:) view method, and the resumableLocks(in:usedIndices:walletId:) static helper are deleted — no remaining callers. -175 LoC. - Body adds a "Resume mode" branch: when preselectedAssetLock is set (Path B), the form collapses to a read-only summary (resumeSummarySection) + submit button. Wallet + lock + slot are fixed by the tapped row, so the picker chrome would only be noise. - IdentitiesContentView.crossWalletResumableLocks marked nonisolated — it's pure, so calling it from tests no longer trips the main-actor warning. - Tests rewritten: the picker's >= 2 floor and its standalone invariants are gone with the picker. The cross-wallet helper retains 8 tests pinning the status floor (Built rejected, Broadcast accepted), the per-wallet anti-join, the cross-wallet scoping, the Int32 -> UInt32 bridge, and empty inputs. 8/8 tests green on iPhone 17 Pro sim (iOS 26.4.1); app build clean (no new warnings). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 2 +- .../Views/CreateIdentityView.swift | 286 ++++++--------- .../CreateIdentityResumableTests.swift | 329 ++++++------------ 3 files changed, 221 insertions(+), 396 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index cb2d80e6aa9..2941cd8cb87 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -332,7 +332,7 @@ struct IdentitiesContentView: View { /// Generic over `AssetLockResumeRow` (the same protocol the /// per-wallet helper uses) so the pure filter is unit-testable /// without spinning up a SwiftData container. - static func crossWalletResumableLocks( + nonisolated static func crossWalletResumableLocks( in locks: [R], usedSlots: Set ) -> [R] { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 061b175fad1..b7fb1eb388d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -20,10 +20,11 @@ import SwiftDashSDK import SwiftData /// Minimum surface of a tracked asset-lock row needed by the -/// resume-picker anti-join. Exists so the pure filter -/// `CreateIdentityView.resumableLocks(in:usedIndices:walletId:)` can -/// be unit-tested with lightweight structs instead of forcing tests -/// to spin up a SwiftData `ModelContainer` just to construct +/// cross-wallet anti-join behind the Identities-tab "Resumable +/// Registrations" section. Exists so the pure filter +/// `IdentitiesContentView.crossWalletResumableLocks(in:usedSlots:)` +/// can be unit-tested with lightweight structs instead of forcing +/// tests to spin up a SwiftData `ModelContainer` just to construct /// `PersistentAssetLock` `@Model` instances. `PersistentAssetLock` /// conforms automatically because it already exposes all three /// properties on its public surface. @@ -94,11 +95,13 @@ struct CreateIdentityView: View { /// Core-address `isUsed` flag). @Query private var allIdentities: [PersistentIdentity] - /// All tracked asset locks across wallets. Filtered down to - /// resumable rows (status >= `InstantSendLocked`, no matching - /// `PersistentIdentity` at the same `(walletId, identityIndexRaw)`) - /// inside `resumableAssetLocks(for:)` so the anti-join — which - /// SwiftData predicates can't express cross-model — stays in Swift. + /// All tracked asset locks across wallets. Used here only to + /// resolve `selectedAssetLockId` (the resume-flow init seed + /// from Path B) back to a concrete row so the submit gate can + /// hand its outpoint to the FFI. The cross-wallet anti-join + /// that drives the Identities-tab Resumable Registrations + /// section lives in `IdentitiesContentView` and runs against + /// its own `@Query`. @Query(sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)]) private var allAssetLocks: [PersistentAssetLock] @@ -208,10 +211,21 @@ struct CreateIdentityView: View { // continues to run if this sheet is dismissed. RegistrationProgressSection(controller: controller) terminalSection(for: controller) + } else if let lock = preselectedAssetLock { + // Path B (Resumable Registrations → Resume): + // wallet + lock + slot are all pinned by the + // tapped row, so the form collapses to a + // read-only summary and a single submit button. + // The picker UI is intentionally absent — the + // user already chose the lock on the Identities + // tab; re-prompting would be noise. + resumeSummarySection(for: lock) + if canSubmit { + submitSection + } } else { sourceWalletSection fundingSection - assetLockPickerIfNeeded amountSection identityIndexSection if canSubmit { @@ -347,17 +361,88 @@ struct CreateIdentityView: View { } } - /// Resume picker shown only when the user picked - /// `.unusedAssetLock` against a wallet. Hidden otherwise so the - /// form stays compact for the common "fund from account" paths. + /// Read-only summary shown in place of the source-wallet / + /// funding-source / amount / identity-index sections when the + /// caller pre-pinned an asset lock via `init(preselectedAssetLock:)` + /// (Path B — Identities tab → "Resumable Registrations" → Resume). + /// The choices are already made; this view confirms them so the + /// user can sanity-check before tapping Create Identity. @ViewBuilder - private var assetLockPickerIfNeeded: some View { - if case .wallet(let walletId) = walletSelection, - fundingSelection == .unusedAssetLock { - assetLockPickerSection(for: walletId) + private func resumeSummarySection(for lock: PersistentAssetLock) -> some View { + let walletLabel = wallets + .first(where: { $0.walletId == lock.walletId })? + .label ?? "Wallet" + Section { + summaryRow("Wallet", value: walletLabel) + summaryRow( + "Asset Lock", + value: shortOutPoint(lock.outPointHex), + monospaced: true + ) + summaryRow( + "Amount", + value: Self.formatDash( + raw: UInt64(bitPattern: Int64(lock.amountDuffs)), + divisor: Double(Self.duffsPerDash) + ) + ) + summaryRow( + "Status", + value: Self.assetLockStatusLabel(rawValue: lock.statusRaw) + ) + summaryRow( + "Identity Slot", + value: "#\(UInt32(bitPattern: lock.identityIndexRaw))" + ) + } header: { + Text("Resuming Registration") + } footer: { + Text( + "Tap Create Identity to complete this in-flight " + + "registration. The wallet, slot, and asset lock are " + + "fixed by the original registration." + ) } } + /// One key/value row used by `resumeSummarySection`. Plain + /// `HStack` rather than `LabeledContent` because the value should + /// flow with the row's content style (so the txid prefix can be + /// monospaced) and `LabeledContent` resists styling overrides + /// on its value slot. + @ViewBuilder + private func summaryRow( + _ label: String, + value: String, + monospaced: Bool = false + ) -> some View { + HStack { + Text(label) + Spacer() + let valueText = Text(value) + .foregroundColor(.secondary) + if monospaced { + valueText.font(.system(.body, design: .monospaced)) + } else { + valueText + } + } + } + + /// First 8 hex chars of the txid plus the vout, derived from the + /// canonical `:` outpoint encoding. Mirrors the row + /// format `ResumableRegistrationRow` uses on the Identities tab + /// so the same lock reads the same way across surfaces. + private func shortOutPoint(_ outPointHex: String) -> String { + let parts = outPointHex.split( + separator: ":", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard parts.count == 2 else { return outPointHex } + return "\(parts[0].prefix(8)):\(parts[1])" + } + @ViewBuilder private func walletAccountSection(for walletId: Data) -> some View { let options = accountOptions(for: walletId) @@ -369,20 +454,12 @@ struct CreateIdentityView: View { Text("\(option.label) — \(option.balanceText)") .tag(Optional(FundingSelection.account(id: option.persistentId))) } - Divider() - Text("Fund from unused Asset Lock") - .tag(Optional(FundingSelection.unusedAssetLock)) } .onChange(of: fundingSelection) { _, newValue in // Pre-fill the amount with the full available balance // of the selected Platform Payment account so the // happy path is one tap. Users can dial it down. amountDash = defaultAmountString(for: newValue) - // Clear any prior asset-lock pick when the user - // switches away from `.unusedAssetLock` — a stale - // selection would otherwise stick around invisible - // and the submit gate would still accept it. - selectedAssetLockId = nil } } header: { Text("Funding Source") @@ -390,111 +467,12 @@ struct CreateIdentityView: View { Text( "Any account on the selected wallet with a balance can fund " + "the identity — Core or Platform Payment. Empty accounts " - + "are hidden. \"Fund from unused Asset Lock\" picks an " - + "existing tracked asset lock instead." + + "are hidden. To resume a prior in-flight registration, " + + "use the Resumable Registrations section on the Identities tab." ) } } - /// Picker for the `.unusedAssetLock` funding mode. Lists tracked - /// asset locks on the current wallet whose status is at least - /// `InstantSendLocked` (`statusRaw >= 2`) AND which have no - /// matching `PersistentIdentity` at the same - /// `(walletId, identityIndexRaw)`. The latter is the anti-join - /// the resume picker semantics call for — a tracked lock whose - /// identity has already registered is "consumed" even if the - /// row hasn't been purged yet. - /// - /// Tapping a row stores the row's `persistentModelID` and pushes - /// the lock's `identityIndexRaw` into the form's identity-index - /// state. The user can't re-pick the index — resume is bound to - /// the lock's original slot. - @ViewBuilder - private func assetLockPickerSection(for walletId: Data) -> some View { - let resumable = resumableAssetLocks(for: walletId) - if resumable.isEmpty { - Section { - Text("No resumable asset locks on this wallet.") - .font(.callout) - .foregroundColor(.secondary) - } header: { - Text("Unused Asset Locks") - } footer: { - Text( - "A resumable asset lock is one that's at InstantSend / " - + "ChainLock state but whose identity registration " - + "didn't complete. Build a fresh one via \"Fund from " - + "account\" instead." - ) - } - } else { - Section { - ForEach(resumable, id: \.persistentModelID) { lock in - assetLockRow(lock) - } - } header: { - Text("Unused Asset Locks") - } footer: { - Text( - "Pick a tracked asset lock to resume its identity " - + "registration. The lock's original identity-index " - + "slot is reused." - ) - } - } - } - - /// Single tappable row in the resume picker. Visually mirrors the - /// `Picker`-style selected checkmark used elsewhere in the form so - /// the interaction model reads the same way. - @ViewBuilder - private func assetLockRow(_ lock: PersistentAssetLock) -> some View { - let isSelected = selectedAssetLockId == lock.persistentModelID - Button { - selectedAssetLockId = lock.persistentModelID - // Pin the identity-index to the lock's slot — resume - // can't change the slot, it consumes the one the lock - // was built for. - identityIndex = UInt32(bitPattern: lock.identityIndexRaw) - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(Self.formatDash( - raw: UInt64(bitPattern: Int64(lock.amountDuffs)), - divisor: Double(Self.duffsPerDash) - )) - .font(.body) - .foregroundColor(.primary) - Text("·") - .foregroundColor(.secondary) - Text("slot #\(UInt32(bitPattern: lock.identityIndexRaw))") - .font(.callout) - .foregroundColor(.secondary) - .monospacedDigit() - } - HStack(spacing: 6) { - Text(Self.assetLockStatusLabel(rawValue: lock.statusRaw)) - .font(.caption) - .foregroundColor(.secondary) - Text("·") - .foregroundColor(.secondary) - Text(Self.relativeDateString(lock.updatedAt)) - .font(.caption) - .foregroundColor(.secondary) - } - } - Spacer() - if isSelected { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - /// Amount (in DASH) to fund the new identity with. Shown for /// Platform Payment and Core / CoinJoin funding sources. @ViewBuilder @@ -680,14 +658,15 @@ struct CreateIdentityView: View { case (.wallet(let walletId), .some): guard let identityIndex = identityIndex else { return false } // The resume path is bound to the lock's original - // identity-index slot. Selecting a lock pins - // `identityIndex` to its `identityIndexRaw`, so the - // collision check below would always trip when the - // lock's slot has been "taken" by the lock itself. - // Resume specifically needs the slot to NOT have a - // `PersistentIdentity` (the anti-join in - // `resumableAssetLocks`); the picker only surfaces - // rows that satisfy that, so we trust the row. + // identity-index slot. Path B's init seeds + // `identityIndex` to the lock's `identityIndexRaw`, + // so the collision check below would always trip + // (the lock's slot has been "taken" by the lock + // itself). Resume specifically needs the slot to NOT + // have a `PersistentIdentity` — the cross-wallet + // anti-join in `IdentitiesContentView` already filtered + // for that on the row the user tapped Resume on, so + // we trust the seed. if fundingSelection == .unusedAssetLock { return selectedAssetLockId != nil } @@ -1227,45 +1206,6 @@ struct CreateIdentityView: View { return allAssetLocks.first { $0.persistentModelID == id } } - /// Resumable asset locks for the given wallet. View entry point — - /// wraps the pure `Self.resumableLocks(in:usedIndices:walletId:)` - /// with the live `@Query` results so the pure helper stays testable - /// without a SwiftData container. - private func resumableAssetLocks(for walletId: Data) -> [PersistentAssetLock] { - Self.resumableLocks( - in: allAssetLocks, - usedIndices: usedIdentityIndices(for: walletId), - walletId: walletId - ) - } - - /// Pure anti-join: returns the subset of `locks` that are - /// resumable for `walletId`. A lock is resumable iff - /// - `walletId` matches, AND - /// - `statusRaw >= 2` (InstantSendLocked or ChainLocked — only - /// finalized locks can fund a Platform identity), AND - /// - the lock's `identityIndexRaw` slot is not in - /// `usedIndices` (i.e. no `PersistentIdentity` already lives - /// on this `(walletId, identityIndex)` pair). - /// - /// SwiftData `#Predicate` can't express "no matching row in - /// another model" cleanly, so this filter runs post-fetch. The - /// signature is intentionally generic over `AssetLockResumeRow` - /// so unit tests can feed it lightweight structs instead of - /// real `@Model` rows. - static func resumableLocks( - in locks: [R], - usedIndices: Set, - walletId: Data - ) -> [R] { - locks.filter { lock in - guard lock.walletId == walletId else { return false } - guard lock.statusRaw >= 2 else { return false } - let slot = UInt32(bitPattern: lock.identityIndexRaw) - return !usedIndices.contains(slot) - } - } - /// The currently-selected Core / CoinJoin account, if any. Used /// for the Core-funded identity-creation path (Standard BIP44/BIP32 /// or CoinJoin). The Rust function `create_funded_asset_lock_proof` diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index eee71229db5..3be68da5492 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -1,21 +1,33 @@ import XCTest @testable import SwiftExampleApp -/// Tests the pure anti-join that powers the "Fund from unused Asset -/// Lock" resume picker (iter 5). The filter has three pieces of -/// business logic that can silently regress: +/// Tests the pure anti-join that powers the Identities-tab +/// "Resumable Registrations" section. This is the *only* surface +/// the user has post-app-restart to see an in-flight registration: +/// the in-memory `RegistrationCoordinator` map is wiped on relaunch, +/// so without the SwiftData-backed cross-wallet filter the user +/// would have no signal that an orphan asset lock is waiting. /// -/// 1. `walletId` match — never surface a lock from a different -/// wallet (would otherwise sign with the wrong key path). -/// 2. `statusRaw >= 2` floor — only InstantSendLocked (2) or -/// ChainLocked (3) locks are resumable; Built (0) and -/// Broadcast (1) aren't final and the Platform side rejects -/// them. -/// 3. Anti-join on `(walletId, identityIndexRaw)` against the -/// set of in-use identity slots — never offer a lock whose -/// slot already has a `PersistentIdentity` row, otherwise the -/// resume submit would collide with an already-registered -/// identity. +/// The filter has four pieces of business logic that can silently +/// regress: +/// +/// 1. `statusRaw >= 1` floor — Built (0) is rejected (tight +/// crash window with no useful UX action), Broadcast (1) and +/// every later status are accepted. The row's *trailing +/// affordance* — spinner vs. Resume button — is staged on +/// `statusRaw >= 2` separately inside `ResumableRegistrationRow`, +/// not at the filter level. +/// 2. Anti-join on `(walletId, identityIndex)` — a slot taken +/// by a `PersistentIdentity` row removes its lock from the +/// surface even if the lock's own status would otherwise +/// qualify. This is what makes the filter "orphan-only". +/// 3. The anti-join is **per-wallet**: slot 0 used on wallet A +/// must not block slot 0 on wallet B. The `UsedSlot` value +/// key (walletId + slot) carries that scoping. +/// 4. `identityIndexRaw` is stored as `Int32` but compared as +/// `UInt32` via `UInt32(bitPattern:)`. A future cast change +/// (e.g. `UInt32(lockIdentityIndexRaw)` — which would trap on +/// negative inputs) must fail loudly here, not in production. /// /// We exercise these with a lightweight `FakeAssetLockRow` so the /// test stays a pure function call — no SwiftData container needed. @@ -30,170 +42,86 @@ final class CreateIdentityResumableTests: XCTestCase { private let walletA = Data(repeating: 0xA1, count: 8) private let walletB = Data(repeating: 0xB2, count: 8) - // MARK: - walletId match + // MARK: - status floor - func testFiltersOutLocksFromOtherWallets() { - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), - FakeAssetLockRow(walletId: walletB, statusRaw: 2, identityIndexRaw: 0), - ] - let result = CreateIdentityView.resumableLocks( - in: locks, - usedIndices: [], - walletId: walletA - ) - XCTAssertEqual(result, [locks[0]]) - } - - // MARK: - statusRaw floor - - func testRejectsBuiltAndBroadcastStatuses() { - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 0, identityIndexRaw: 0), // Built - FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 1), // Broadcast + func testRejectsBuiltLocks() { + // Built (0) is the tight crash window between "build TX" and + // "broadcast TX". There's no useful resume action, so it + // shouldn't appear on the Identities tab at all. + let locks = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 0, identityIndexRaw: 0) ] - let result = CreateIdentityView.resumableLocks( + let result = IdentitiesContentView.crossWalletResumableLocks( in: locks, - usedIndices: [], - walletId: walletA + usedSlots: [] ) XCTAssertTrue(result.isEmpty) } - func testAcceptsInstantSendLockedAndChainLocked() { - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), // ISLock - FakeAssetLockRow(walletId: walletA, statusRaw: 3, identityIndexRaw: 1), // CLock - ] - let result = CreateIdentityView.resumableLocks( - in: locks, - usedIndices: [], - walletId: walletA + /// Broadcast (1) must surface even though it's not actionable — + /// the row renders a spinner ("Waiting for InstantSendLock…") + /// until SPV delivers the IS lock and the persister flips it + /// to (2). Hiding (1) would create the UX asymmetry where a + /// just-broadcast lock vanishes from the UI for ~10-30 seconds + /// and then reappears at (2) — confusing rather than reassuring. + func testAcceptsBroadcastForVisibility() { + let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: [] ) - XCTAssertEqual(result.count, 2) + XCTAssertEqual(result, [lock]) } - /// Defensive: any unknown future status (e.g. 4) should still - /// pass the `>= 2` floor. If we ever flip from "≥2 means final" - /// to a closed set, this test will need updating — that's - /// intentional, the surprise is signal. - func testForwardCompatibleStatusFloor() { + func testAcceptsInstantSendLockedAndChainLocked() { let locks = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 4, identityIndexRaw: 0) - ] - let result = CreateIdentityView.resumableLocks( - in: locks, - usedIndices: [], - walletId: walletA - ) - XCTAssertEqual(result.count, 1) - } - - // MARK: - anti-join on used slots - - func testFiltersOutLocksWhoseSlotIsAlreadyUsed() { - let locks: [FakeAssetLockRow] = [ FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), FakeAssetLockRow(walletId: walletA, statusRaw: 3, identityIndexRaw: 1), - FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 2), - ] - let result = CreateIdentityView.resumableLocks( - in: locks, - usedIndices: [0, 2], - walletId: walletA - ) - XCTAssertEqual(result, [locks[1]]) // only slot 1 is unused - } - - /// The `usedIndices` set is per-wallet by construction (the - /// view derives it from `usedIdentityIndices(for: walletId)`), - /// so a slot used on wallet B must not bleed into wallet A's - /// pickability check. We model that by simply not including - /// wallet-B's slots in `usedIndices` when filtering for A. - func testUsedSlotsScopedPerWallet() { - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), ] - // Pretend wallet B has slot 0 used, but the caller — which - // is filtering for wallet A — never tells us that. Lock on - // wallet A at slot 0 must stay resumable. - let result = CreateIdentityView.resumableLocks( + let result = IdentitiesContentView.crossWalletResumableLocks( in: locks, - usedIndices: [], - walletId: walletA + usedSlots: [] ) XCTAssertEqual(result, locks) } - /// `identityIndexRaw` is `Int32` (the storage row type) but the - /// in-use index set is `Set` (the FFI / wallet-side - /// type). The filter bridges via `UInt32(bitPattern:)`. A - /// negative `Int32` therefore maps to a high `UInt32`. This - /// test pins that conversion so a future cast change (e.g. - /// `UInt32(lockIdentityIndexRaw)` which would trap on negative) - /// fails loudly here instead of crashing in production. - func testNegativeIdentityIndexBridgesViaBitPattern() { - let negativeIndex: Int32 = -1 - let bridged = UInt32(bitPattern: negativeIndex) // 0xFFFF_FFFF - - // Lock with slot -1 / 0xFFFF_FFFF — when that exact bridged - // value is in `usedIndices`, the lock must be filtered out. - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: negativeIndex) - ] - let blocked = CreateIdentityView.resumableLocks( - in: locks, - usedIndices: [bridged], - walletId: walletA - ) - XCTAssertTrue(blocked.isEmpty) - - // ...and when it's NOT in `usedIndices`, the lock stays. - let kept = CreateIdentityView.resumableLocks( - in: locks, - usedIndices: [], - walletId: walletA + /// Defensive: any unknown future status (e.g. 4) should still + /// pass the `>= 1` floor. If we ever flip from "≥1 means + /// surfaced" to a closed set, this test will need updating — + /// that's intentional, the surprise is signal. + func testForwardCompatibleStatusFloor() { + let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 4, identityIndexRaw: 0) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: [] ) - XCTAssertEqual(kept, locks) + XCTAssertEqual(result, [lock]) } - // MARK: - empty inputs + // MARK: - anti-join - func testEmptyLocksListReturnsEmpty() { - let result = CreateIdentityView.resumableLocks( - in: [FakeAssetLockRow](), - usedIndices: [0, 1, 2], - walletId: walletA + func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed() { + // A registration that completed normally leaves both a + // `PersistentAssetLock` and a `PersistentIdentity` row. The + // lock is "consumed" even if it hasn't been purged yet, so + // the surface must hide it. + let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 7) + let usedSlots: Set = [ + IdentitiesContentView.UsedSlot(walletId: walletA, slot: 7) + ] + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: usedSlots ) XCTAssertTrue(result.isEmpty) } - // MARK: - cross-wallet anti-join (Identities-tab surface) - - /// `IdentitiesContentView.resumableRegistrationsSection` is the - /// surface that catches orphan locks after an app crash — the - /// in-memory `RegistrationCoordinator` map is wiped on restart, - /// so this section is the user's only signal that an asset - /// lock at `InstantSendLocked` / `ChainLocked` is waiting to be - /// resumed. It uses `crossWalletResumableLocks(in:usedSlots:)` - /// instead of the per-wallet helper because the section spans - /// every wallet in one pass. - /// - /// The pair of tests below pin the two pieces of business - /// logic that differ from the per-wallet form: - /// - /// 1. The `usedSlots` set is keyed by - /// `(walletId, identityIndex)` — a slot used on wallet A - /// must not block the same numerical slot on wallet B. - /// 2. The cross-wallet pass still enforces the `statusRaw >= - /// 2` floor — Built / Broadcast locks aren't resumable - /// regardless of which wallet they live on. - func testCrossWalletFilterDoesNotBleedSlotsAcrossWallets() { + func testDoesNotBleedUsedSlotsAcrossWallets() { // Same numerical slot (0) on both wallets, but only // wallet-A's slot is taken by an existing identity. The - // lock on wallet B at slot 0 must stay resumable — the + // lock on wallet B at slot 0 must stay surfaced — the // user's identity on A has no bearing on B's slot pool. - let locks: [FakeAssetLockRow] = [ + let locks = [ FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0), FakeAssetLockRow(walletId: walletB, statusRaw: 2, identityIndexRaw: 0), ] @@ -204,93 +132,50 @@ final class CreateIdentityResumableTests: XCTestCase { in: locks, usedSlots: usedSlots ) - // Only wallet B's lock survives. XCTAssertEqual(result, [locks[1]]) } - func testCrossWalletFilterEnforcesStatusFloor() { - // The Resumable Registrations section uses a `>= 1` - // (Broadcast) floor — strictly lower than the per-wallet - // picker's `>= 2` floor. Reason: the section is the user's - // only post-restart signal that *any* registration is in - // flight, including pre-IS-lock locks that aren't - // tappable yet. Built (0) is still rejected — that's a - // tight crash window with no useful UX recovery action. - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 0, identityIndexRaw: 0), // Built — rejected - FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 1), // Broadcast — accepted - FakeAssetLockRow(walletId: walletB, statusRaw: 3, identityIndexRaw: 0), // ChainLocked — accepted - ] - let result = IdentitiesContentView.crossWalletResumableLocks( - in: locks, - usedSlots: [] - ) - XCTAssertEqual(result, [locks[1], locks[2]]) - } + // MARK: - Int32 -> UInt32 bridge - /// The whole point of dropping the cross-wallet floor from - /// `>= 2` to `>= 1`: a lock at `Broadcast` (1) must surface - /// on the Identities tab so a user who killed the app between - /// "broadcast TX" and "wait for IS lock" sees their in-flight - /// registration. Without this, the row would silently vanish - /// until SPV delivered the IS lock and the persister flipped - /// the row to (2) — confusing rather than reassuring. - func testCrossWalletFilterIncludesBroadcastForVisibility() { - let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0) - let result = IdentitiesContentView.crossWalletResumableLocks( - in: [lock], - usedSlots: [] + /// `identityIndexRaw` is `Int32` (the storage row type) but the + /// `UsedSlot.slot` field is `UInt32` (the FFI / wallet-side + /// type). The filter bridges via `UInt32(bitPattern:)`. A + /// negative `Int32` maps to a high `UInt32`. This test pins + /// the conversion so a future change to `UInt32(...)` — + /// which would trap on negative inputs — fails loudly here + /// instead of crashing in production. + func testNegativeIdentityIndexBridgesViaBitPattern() { + let negativeIndex: Int32 = -1 + let bridged = UInt32(bitPattern: negativeIndex) // 0xFFFF_FFFF + let lock = FakeAssetLockRow( + walletId: walletA, + statusRaw: 2, + identityIndexRaw: negativeIndex ) - XCTAssertEqual(result, [lock]) - } - /// The per-wallet picker (`CreateIdentityView.resumableLocks`) - /// has a *different*, stricter floor: `>= 2`. It only surfaces - /// locks that can fund a Platform identity right now — - /// Broadcast locks have no usable proof yet. This test pins - /// the asymmetry so a future "unify the floors" refactor - /// fails loudly here. - func testPickerFloorStaysStricterThanSectionFloor() { - let broadcast = FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0) - - let pickerResult = CreateIdentityView.resumableLocks( - in: [broadcast], - usedIndices: [], - walletId: walletA - ) - XCTAssertTrue( - pickerResult.isEmpty, - "Per-wallet picker must reject Broadcast (1)" + // With the bridged slot in `usedSlots`, the lock is filtered. + let blocked = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: [IdentitiesContentView.UsedSlot(walletId: walletA, slot: bridged)] ) + XCTAssertTrue(blocked.isEmpty) - let sectionResult = IdentitiesContentView.crossWalletResumableLocks( - in: [broadcast], + // Without it, the lock stays. + let kept = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], usedSlots: [] ) - XCTAssertEqual( - sectionResult, - [broadcast], - "Resumable Registrations section must accept Broadcast (1)" - ) + XCTAssertEqual(kept, [lock]) } - /// Edge case: a lock at a slot that's marked used on its OWN - /// wallet must still be filtered out. This is the orphan- - /// recovery semantics in reverse — if the registration has - /// completed (a `PersistentIdentity` row exists at the same - /// `(walletId, identityIndex)`), the lock is no longer - /// "resumable", it's consumed. Even if its - /// `PersistentAssetLock` row hasn't been purged yet. - func testCrossWalletFilterFiltersOutOwnWalletUsedSlot() { - let locks: [FakeAssetLockRow] = [ - FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 7) - ] - let usedSlots: Set = [ - IdentitiesContentView.UsedSlot(walletId: walletA, slot: 7) - ] + // MARK: - empty inputs + + func testEmptyLocksReturnsEmpty() { let result = IdentitiesContentView.crossWalletResumableLocks( - in: locks, - usedSlots: usedSlots + in: [FakeAssetLockRow](), + usedSlots: [ + IdentitiesContentView.UsedSlot(walletId: walletA, slot: 0) + ] ) XCTAssertTrue(result.isEmpty) } From 02a15497c6267737243066e72dd081d354ad8dbf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 17:21:16 +0700 Subject: [PATCH 22/54] fix(SwiftExampleApp): prevent duplicate-tap Resume during in-flight registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a normal in-session registration, the asset lock reaches statusRaw >= 1 well before the persister writes a PersistentIdentity row. The Resumable Registrations section's anti-join only excluded identity-claimed slots, so the same lock was visible in BOTH "Pending Registrations" (in-memory, coordinator-driven) and "Resumable Registrations" (SwiftData-backed) for the ~tens of seconds between asset-lock broadcast and identity-row write. Tapping Resume on the second surface raced a duplicate FFI call against the original. Fix in three layers: 1. RegistrationCoordinator.startRegistration now guards on the existing controller's phase. Re-entry on .preparingKeys / .inFlight / .completed returns the existing controller without disrupting it (was: reset to .preparingKeys and re-submit). The original guard inside IdentityRegistrationController.submit was bypassed because enterPreparingKeys() unconditionally overwrote the phase BEFORE submit's guard ran. 2. IdentityRegistrationController.submit hardens its phase guard to match: defensive single-flight at the controller layer (.inFlight and .completed rejected). .failed remains allowed so the coordinator's retry path stays alive. 3. ResumableRegistrationsList is extracted as a coordinator-observing subview (@ObservedObject) so the section's filter input is the UNION of identity-claimed slots and in-flight controller slots. New IdentityRegistrationController.Phase.isActive predicate centralizes the "this phase holds its slot" rule so the rule can't drift between the Pending and Resumable surfaces. Also: tighten canSubmit's .unusedAssetLock gate to require the lock row still exists (not just an id) — closes a small confusing-error path when the row gets deleted between Path B init and submit. Tests: 10/10 green. Two new cases — testInFlightSlotIsExcludedFromResumableSurface pins the union semantics, testControllerPhaseIsActivePredicate exhaustively pins the Phase.isActive predicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 214 +++++++++++------- .../IdentityRegistrationController.swift | 46 +++- .../Services/RegistrationCoordinator.swift | 45 +++- .../Views/CreateIdentityView.swift | 9 +- .../CreateIdentityResumableTests.swift | 55 +++++ 5 files changed, 268 insertions(+), 101 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 2941cd8cb87..6f58031c047 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -238,100 +238,59 @@ struct IdentitiesContentView: View { /// Empty when there are no orphan locks; collapses to nothing /// in that case so the rest of the screen isn't pushed down by /// an empty header. + /// Wraps the Resumable Registrations section in a dedicated view + /// that observes `RegistrationCoordinator` directly. Necessary + /// because the section's filter needs to consult **both** the + /// SwiftData @Query results AND the coordinator's in-memory + /// controller map: an in-flight registration owns its slot + /// transiently between `asset-lock-broadcast` and `PersistentIdentity- + /// written`, and during that window the orphan-lock anti-join + /// alone would let the same slot surface as both "Pending" and + /// "Resumable" → user could tap Resume and race a duplicate + /// FFI against the original. @ViewBuilder private var resumableRegistrationsSection: some View { - let locks = resumableAssetLocks - if !locks.isEmpty { - Section("Resumable Registrations (\(locks.count))") { - ForEach(locks) { lock in - ResumableRegistrationRow( - lock: lock, - walletLabel: walletDisplayLabel(for: lock.walletId), - onResume: { - resumingAssetLock = lock - } - ) - } - } - } - } - - /// Cross-wallet variant of the per-wallet resume picker filter - /// implemented at `CreateIdentityView.resumableLocks(...)`. Same - /// anti-join logic, but the per-wallet `usedIndices` set is - /// generalized to a per-`(walletId, identityIndex)` set so we - /// can filter all wallets in one pass. - /// - /// Independent of `RegistrationCoordinator` — this is purely a - /// SwiftData read. Survives app restarts because the underlying - /// `PersistentAssetLock` and `PersistentIdentity` rows are - /// disk-persisted. - private var resumableAssetLocks: [PersistentAssetLock] { - let usedSlots: Set = Set( - identities.compactMap { identity -> UsedSlot? in - guard let walletId = identity.wallet?.walletId else { - return nil - } - return UsedSlot(walletId: walletId, slot: identity.identityIndex) - } - ) - return Self.crossWalletResumableLocks( - in: allAssetLocks, - usedSlots: usedSlots + ResumableRegistrationsList( + coordinator: walletManager.registrationCoordinator, + allAssetLocks: allAssetLocks, + allWallets: allWallets, + allIdentities: identities, + resumingAssetLock: $resumingAssetLock ) } - /// Wallet display label for the Resume row's sub-line. Prefers - /// the wallet's stored name (via `PersistentWallet.label`'s - /// short-hex fallback), so the row shows "MyWallet" / "Wallet - /// a1b2c3d4…" rather than a raw 32-byte id dump. - private func walletDisplayLabel(for walletId: Data) -> String { - if let wallet = allWallets.first(where: { $0.walletId == walletId }) { - return wallet.label - } - // Defensive — should never hit this branch in practice - // because every asset lock is owned by a wallet that's - // in `allWallets`. Mirrors the same fallback shape that - // `PersistentWallet.label` uses so the cosmetic doesn't - // diverge. - let hex = walletId.prefix(4) - .map { String(format: "%02x", $0) } - .joined() - return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" - } - /// Pure anti-join across all wallets. A lock is *visible* on the /// Resumable Registrations surface iff /// - `statusRaw >= 1` (Broadcast or higher), AND - /// - no `(walletId, identityIndex)` slot is already claimed by - /// a `PersistentIdentity` row. + /// - no `(walletId, identityIndex)` slot is in `usedSlots`. + /// + /// `usedSlots` is the **union** of two sources of slot + /// occupancy (the caller assembles it): + /// 1. Persisted `PersistentIdentity` rows — registrations that + /// have completed and written an identity to SwiftData. + /// 2. In-memory `RegistrationCoordinator` controllers in + /// `.preparingKeys` or `.inFlight` — registrations whose + /// asset lock has been broadcast but whose identity row + /// hasn't landed yet. Including these prevents a transient + /// window where the same lock would surface here *and* in + /// the in-memory Pending Registrations list, letting the + /// user race a duplicate Resume tap against the original. /// - /// The floor here (`>= 1`, Broadcast) is intentionally **lower - /// than the per-wallet picker's floor** in - /// `CreateIdentityView.resumableLocks(...)` (which uses `>= 2`, - /// InstantSendLocked). Reason: the picker only surfaces locks - /// that can fund a Platform identity *right now* — only IS- - /// or chain-locked locks have a usable proof — so its row is - /// always immediately actionable. This section, by contrast, - /// is the user's only signal that *any* registration is - /// mid-flight after an app restart. A lock at Broadcast (1) is - /// in mid-handoff — SPV will deliver the InstantSendLock - /// shortly and the persister will flip it to (2), at which - /// point the row's trailing affordance flips from a spinner to - /// a Resume button automatically (SwiftData `@Query` is - /// reactive). Hiding (1) entirely would create the UX - /// asymmetry where users see their just-broadcast lock at - /// (1) vanish from the UI, then reappear seconds later at - /// (2) — confusing rather than reassuring. + /// The status floor (`>= 1`, Broadcast) is intentionally low: + /// a lock at Broadcast (1) is in mid-handoff — SPV will + /// deliver the InstantSendLock shortly and the persister will + /// flip it to (2), at which point the row's trailing affordance + /// flips from a spinner to a Resume button automatically + /// (SwiftData `@Query` is reactive). Hiding (1) entirely would + /// create a UX asymmetry where the just-broadcast lock vanishes + /// from the UI then reappears seconds later at (2). /// - /// `statusRaw == 0` (Built but never broadcast) is still - /// filtered out: it's a tight crash window between TX build - /// and broadcast, and there's no useful UX action to take. - /// A re-broadcast would have to come from a different surface. + /// `statusRaw == 0` (Built but never broadcast) is filtered + /// out: a tight crash window between TX build and broadcast + /// with no useful UX action to take. /// - /// Generic over `AssetLockResumeRow` (the same protocol the - /// per-wallet helper uses) so the pure filter is unit-testable - /// without spinning up a SwiftData container. + /// Generic over `AssetLockResumeRow` so the pure filter is + /// unit-testable without a SwiftData container. nonisolated static func crossWalletResumableLocks( in locks: [R], usedSlots: Set @@ -424,6 +383,95 @@ struct IdentitiesContentView: View { } } +/// Section view backing the Identities tab's "Resumable Registrations" +/// surface. Observes `RegistrationCoordinator` (`@ObservedObject`) so +/// the in-memory controller map is part of the filter input, not just +/// the SwiftData-backed identity rows: a controller in `.preparingKeys` +/// or `.inFlight` owns its slot transiently before a `PersistentIdentity` +/// row exists for it, and during that window the orphan-lock anti-join +/// would otherwise surface the same slot as both "Pending" and +/// "Resumable" → a duplicate Resume tap could race a second FFI for +/// the same outpoint. +/// +/// Phase-change reactivity within an existing controller is best- +/// effort. The coordinator's `@Published controllers` dict re-emits +/// on add/remove but not on phase transitions inside an existing +/// entry. The two transitions that need re-render are +/// `.inFlight → .completed` (handled — the persister writes a +/// `PersistentIdentity` row, the @Query re-fires) and +/// `.inFlight → .failed` (not handled directly; the row re-surfaces +/// once the user dismisses the failed Pending entry, which mutates +/// the dict). The latter delay is acceptable given the explicit +/// dismiss UX on `.failed` rows. +private struct ResumableRegistrationsList: View { + @ObservedObject var coordinator: RegistrationCoordinator + let allAssetLocks: [PersistentAssetLock] + let allWallets: [PersistentWallet] + let allIdentities: [PersistentIdentity] + @Binding var resumingAssetLock: PersistentAssetLock? + + var body: some View { + let locks = resumableLocks + if !locks.isEmpty { + Section("Resumable Registrations (\(locks.count))") { + ForEach(locks) { lock in + ResumableRegistrationRow( + lock: lock, + walletLabel: walletDisplayLabel(for: lock.walletId), + onResume: { + resumingAssetLock = lock + } + ) + } + } + } + } + + /// Filter is `anti-join(identity-claimed slots ∪ in-flight slots)` + /// over `crossWalletResumableLocks`. The union is the key + /// fix: identity rows alone leave a window where the asset + /// lock is already broadcast but the registration hasn't + /// persisted an identity yet, and during that window the + /// row would double-count in both Pending and Resumable. + private var resumableLocks: [PersistentAssetLock] { + let identitySlots: Set = Set( + allIdentities.compactMap { identity in + guard let walletId = identity.wallet?.walletId else { + return nil + } + return IdentitiesContentView.UsedSlot( + walletId: walletId, + slot: identity.identityIndex + ) + } + ) + let activeSlots: Set = Set( + coordinator.controllers + .filter { _, controller in controller.phase.isActive } + .map { (key, _) in + IdentitiesContentView.UsedSlot( + walletId: key.walletId, + slot: key.identityIndex + ) + } + ) + return IdentitiesContentView.crossWalletResumableLocks( + in: allAssetLocks, + usedSlots: identitySlots.union(activeSlots) + ) + } + + private func walletDisplayLabel(for walletId: Data) -> String { + if let wallet = allWallets.first(where: { $0.walletId == walletId }) { + return wallet.label + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + /// Single row in the "Resumable Registrations" section. Renders the /// lock summary (txid prefix, amount, status, owning wallet, slot) /// plus a compact Resume button that fires the caller-supplied diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift index 5f19b1650f6..55456119bd2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift @@ -40,6 +40,30 @@ final class IdentityRegistrationController: ObservableObject { /// the coordinator's map until the user dismisses it /// manually. case failed(String) + + /// Whether the controller is currently holding its slot + /// against a fresh registration. Used by the Resumable + /// Registrations surface to hide orphan asset locks whose + /// slot is mid-flight — otherwise the same lock could + /// appear in both Pending and Resumable lists during the + /// window between asset-lock broadcast and PersistentIdentity + /// write, letting the user race a duplicate Resume tap + /// against the original FFI call. + /// + /// `.completed` is NOT active here: by the time the + /// controller flips to `.completed` the persister has + /// (or is about to) write a `PersistentIdentity` row, + /// which the identity-slot anti-join already covers. + /// `.failed` is NOT active either: the user is expected + /// to retry, so the lock should resurface for selection. + var isActive: Bool { + switch self { + case .preparingKeys, .inFlight: + return true + case .idle, .completed, .failed: + return false + } + } } /// Current phase. Updates flow: @@ -77,17 +101,29 @@ final class IdentityRegistrationController: ObservableObject { phase = .preparingKeys } - /// Submit the registration. Single-flighted by the coordinator: - /// callers should check `phase != .inFlight` before invoking, - /// otherwise the controller silently ignores re-submits to keep - /// the FFI call exclusive. + /// Submit the registration. Defensively rejects any phase that + /// shouldn't fire a fresh FFI call: + /// - `.inFlight`: a second FFI call would race the first, + /// ending with a misleading "asset lock consumed" failure. + /// - `.completed`: re-submitting after success would flip the + /// UI from "Done" back to a spinner before failing on the + /// consumed lock. + /// `.idle`, `.preparingKeys`, and `.failed` are allowed — the + /// coordinator drives the legitimate-restart flow through them + /// (callers must call `enterPreparingKeys()` before `submit()`, + /// `failed → preparingKeys → submit` is how a user retries). /// /// `body` performs the actual FFI call. It runs detached on a /// background priority and reports the identity id on success /// or rethrows on failure. The controller flips `phase` to /// `.completed` / `.failed` accordingly. func submit(body: @escaping () async throws -> Data) { - guard phase != .inFlight else { return } + switch phase { + case .idle, .preparingKeys, .failed: + break + case .inFlight, .completed: + return + } phase = .inFlight lastSubmittedAt = Date() task = Task { [weak self] in diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift index 414bab65eb1..339d367ba51 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/RegistrationCoordinator.swift @@ -75,26 +75,47 @@ final class RegistrationCoordinator: ObservableObject { /// controller for `CreateIdentityView` to bind a /// `RegistrationProgressView` against. /// - /// Single-flighting is handled inside - /// `IdentityRegistrationController.submit` — a second call for - /// the same slot while the first is in flight is silently - /// ignored at the controller layer. + /// Single-flighting is enforced here at the coordinator level + /// (rather than just inside `IdentityRegistrationController.submit`) + /// because the controller's `enterPreparingKeys()` unconditionally + /// overwrites `phase`; without a phase check before that call, a + /// second tap on the same slot during the FFI window would set + /// `.inFlight → .preparingKeys → .inFlight`, racing two FFI calls + /// for the same asset lock. The Resumable Registrations section + /// surfaces orphan locks based on the absence of a + /// `PersistentIdentity` row, which only lands after the FFI + /// returns — so during the lock-broadcast-to-identity-write + /// window, the same slot was visible in both Pending and + /// Resumable surfaces and could be double-tapped. func startRegistration( walletId: Data, identityIndex: UInt32, body: @escaping () async throws -> Data ) -> IdentityRegistrationController { let key = SlotKey(walletId: walletId, identityIndex: identityIndex) - let controller: IdentityRegistrationController if let existing = controllers[key] { - controller = existing - } else { - controller = IdentityRegistrationController( - walletId: walletId, - identityIndex: identityIndex - ) - controllers[key] = controller + switch existing.phase { + case .preparingKeys, .inFlight, .completed: + // Active or just-completed — don't re-enter. Returning + // the existing controller lets the caller bind to its + // progress / terminal state without disrupting it. + return existing + case .idle, .failed: + // Legitimate restart paths: a brand-new idle + // controller (shouldn't happen via the standard + // entry but safe to allow), or a user-initiated + // retry after a failure. + existing.enterPreparingKeys() + existing.submit(body: body) + scheduleRetentionSweep(key: key, controller: existing) + return existing + } } + let controller = IdentityRegistrationController( + walletId: walletId, + identityIndex: identityIndex + ) + controllers[key] = controller controller.enterPreparingKeys() controller.submit(body: body) scheduleRetentionSweep(key: key, controller: controller) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index b7fb1eb388d..99936466a4c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -668,7 +668,14 @@ struct CreateIdentityView: View { // for that on the row the user tapped Resume on, so // we trust the seed. if fundingSelection == .unusedAssetLock { - return selectedAssetLockId != nil + // Resolve the id to a live row before enabling + // submit — the row could have been deleted between + // Path B's init-seed and now (e.g. another flow + // consumed the same outpoint, or SwiftData pruning + // collapsed it). Submitting with a stale id would + // fall into the dispatch's "not yet supported" + // branch and show a confusing error. + return selectedAssetLock != nil } // Block submit on collision with an existing identity's // registration index. The picker shows a red collision diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index 3be68da5492..2d866bb058c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -179,4 +179,59 @@ final class CreateIdentityResumableTests: XCTestCase { ) XCTAssertTrue(result.isEmpty) } + + // MARK: - in-flight controller exclusion + + /// Regression for the double-counting bug: during a normal + /// in-session registration, the asset lock reaches `Broadcast` + /// (or higher) **before** the persister writes a + /// `PersistentIdentity` row. Without including in-flight + /// controller slots in `usedSlots`, the same lock would + /// appear in BOTH the in-memory Pending Registrations list + /// and the SwiftData-backed Resumable Registrations section + /// — and a second tap on Resume would race a duplicate FFI + /// call against the original. The fix is structural: the + /// `ResumableRegistrationsList` view unions identity-claimed + /// slots with `controller.phase.isActive` slots before + /// calling `crossWalletResumableLocks`. The unioned set is + /// what gets passed here; this test pins that the filter + /// honors active-slot occupancy the same way it honors + /// identity-row occupancy. + func testInFlightSlotIsExcludedFromResumableSurface() { + let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 3) + // No `PersistentIdentity` exists at (walletA, 3) yet, but a + // controller in `.inFlight` does. The union of identity + // slots (empty) and active slots (one) must hide the lock. + let activeSlots: Set = [ + IdentitiesContentView.UsedSlot(walletId: walletA, slot: 3) + ] + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: activeSlots + ) + XCTAssertTrue(result.isEmpty) + } + + /// Exhaustive predicate test for which controller phases hold + /// the slot. The Resumable surface uses this to decide which + /// in-memory controllers block their lock from re-appearing + /// in the section. Changing this predicate without thinking + /// about both the Pending and Resumable surfaces is how + /// double-tap-Resume bugs come back. + func testControllerPhaseIsActivePredicate() { + XCTAssertFalse(IdentityRegistrationController.Phase.idle.isActive, + ".idle: pre-submit — no slot occupancy yet") + XCTAssertTrue(IdentityRegistrationController.Phase.preparingKeys.isActive, + ".preparingKeys: keys derived; FFI imminent — slot held") + XCTAssertTrue(IdentityRegistrationController.Phase.inFlight.isActive, + ".inFlight: FFI mid-call — slot held") + XCTAssertFalse( + IdentityRegistrationController.Phase + .completed(identityId: Data(repeating: 0xCC, count: 32)) + .isActive, + ".completed: PersistentIdentity row covers the slot via the identity anti-join — don't double-block" + ) + XCTAssertFalse(IdentityRegistrationController.Phase.failed("nope").isActive, + ".failed: user is expected to retry — let the lock resurface") + } } From b80a6f9a920b070112f733c258107749ff191fc5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 17:29:30 +0700 Subject: [PATCH 23/54] refactor(SwiftExampleApp): centralize asset-lock status constants + plan doc sync The AssetLockStatus discriminants (0/1/2/3 -> Built/Broadcast/ InstantSendLocked/ChainLocked) are protocol constants from the Rust side. Until this commit four separate views each carried their own copy of the case-block label mapping, and the >= 2 "is fundable" threshold lived inline at every usage site. Consolidates into a single PersistentAssetLock extension in the example app: - statusLabel: protocol-discriminant -> human-readable string. - canFundIdentity: statusRaw >= 2 (resume button gates on this). - isVisibleAsResumable: statusRaw >= 1 (Resumable section surfaces this). - shortOutPointDisplay: txid-prefix-plus-vout format used by every row. The fundability/visibility predicates live on the AssetLockResumeRow protocol so test fakes get them for free without an explicit PersistentAssetLock instance. Also: - Delete unused `relativeDateString` and `assetLockStatusLabel` statics from CreateIdentityView (leftovers from the in-form picker removed in f466b7c4c7). - Remove now-duplicate `statusLabel(_:)` helpers from StorageRecordDetailViews, StorageModelListViews, and IdentitiesContentView. - Remove the duplicated `shortOutPoint(_:)` helper that lived in both CreateIdentityView and IdentitiesContentView. - Sync the iter 5 prose in CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md to reflect (a) the >= 1 visibility floor + active-controller anti-join and (b) the Identities-tab resume surface that replaced the original in-form picker. Tests: 10/10 green, no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 64 ++++++++++-------- .../Core/Views/IdentitiesContentView.swift | 38 ++--------- .../Utils/PersistentAssetLockDisplay.swift | 65 +++++++++++++++++++ .../Views/CreateIdentityView.swift | 40 +----------- .../Views/StorageModelListViews.swift | 12 +--- .../Views/StorageRecordDetailViews.swift | 14 +--- 6 files changed, 115 insertions(+), 118 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index 31544ce2bf4..d47bd73b9fc 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -920,38 +920,48 @@ in storage. Predates our changes. Follow-up issue, not this PR. --- -### Iter 5 — "Fund from unused Asset Lock" picker + crash recovery +### Iter 5 — Resume an in-flight asset lock + crash recovery ✅ **DONE** **Goal**: enable resuming a tracked asset lock when the previous registration didn't complete. Validate crash recovery end-to-end. -**Resume picker semantics**: an "unused" lock is one at status -`InstantSendLocked` or `ChainLocked` for which **no -`PersistentIdentity` exists** at the same `(walletId, -identityIndex)`. (Not `identityIndex == nil` — that field is -always set on a tracked lock.) - -**Steps**: - -1. **Resume-picker `@Query`** on `PersistentAssetLock` filtered - by `walletId == selectedWalletId AND statusRaw >= 2 AND no - matching PersistentIdentity at (walletId, identityIndexRaw)`. - Compound query — may need a post-fetch filter for the - anti-join. - -2. **Update `CreateIdentityView`** so picking - `.unusedAssetLock` and a specific tracked lock from the list - wires through to `registrationCoordinator.startRegistration( - walletId:, identityIndex: lock.identityIndexRaw, funding: - .fromExistingAssetLock(outPoint: lock.outPointHex), …)`. - -3. **Crash-recovery validation**: trigger a registration, kill - the app between `Broadcast` and Platform submission. Re-launch. - Verify the tracked lock appears in `StorageExplorerView` / - `WalletMemoryExplorerView`. Open CreateIdentity → "Fund from - unused Asset Lock" → submit → identity registers, tracked - lock removed. +**Resume surface semantics**: a "resumable" lock is one at +`statusRaw >= 1` (Broadcast / InstantSendLocked / ChainLocked) +for which **no `PersistentIdentity` exists** at the same +`(walletId, identityIndex)` **AND** no in-memory +`RegistrationCoordinator` controller in `.preparingKeys` / +`.inFlight` claims that slot. The union of identity-claimed +and active-controller slots is what's anti-joined against the +asset-lock rows. The `statusRaw >= 1` floor surfaces in-flight +Broadcast locks too (rendered with a spinner instead of a +Resume button) so the user sees visual continuity through the +IS-lock arrival. + +**Steps (delivered)**: + +1. **FFI + Swift SDK wrapper** for `IdentityFunding::FromExistingAssetLock`: + `platform_wallet_resume_identity_with_existing_asset_lock_signer` + in `rs-platform-wallet-ffi`, and + `ManagedPlatformWallet.resumeIdentityWithAssetLock(...)` mirroring + the existing `registerIdentityWithFunding(...)` shape. + +2. **Resumable Registrations section on the Identities tab** + (`IdentitiesContentView.swift`). SwiftData-backed, anti-joins + identity rows + in-flight controllers, surfaces every orphan + asset lock. Tap **Resume** → opens `CreateIdentityView` with + the lock pre-pinned via `init(preselectedAssetLock:)`. Submit + dispatches through `submitResumed` → coordinator → Swift SDK + → FFI. *(Note: the original plan called for an in-form + "Fund from unused Asset Lock" sub-picker inside + `CreateIdentityView`. That was implemented in commit + `f31ee5d842` and then removed in `f466b7c4c7` — the Identities-tab + section is strictly better UX: auto-surfaces, pre-fills, + fewer taps. Calling a previously-built lock a "Funding + source" was also conceptually wrong — it's resumption, not + funding.)* + +3. **Crash-recovery validation**: see Iter 5 follow-up below. --- diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 6f58031c047..926ef69aad4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -486,7 +486,7 @@ private struct ResumableRegistrationRow: View { var body: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { - Text("Asset Lock \(shortOutPoint(lock.outPointHex))") + Text("Asset Lock \(lock.shortOutPointDisplay)") .font(.body) .lineLimit(1) HStack(spacing: 6) { @@ -496,7 +496,7 @@ private struct ResumableRegistrationRow: View { Text("·") .font(.caption) .foregroundColor(.secondary) - Text(statusLabel(lock.statusRaw)) + Text(lock.statusLabel) .font(.caption) .foregroundColor(.secondary) Text("·") @@ -518,16 +518,16 @@ private struct ResumableRegistrationRow: View { .padding(.vertical, 2) } - /// Trailing view that depends on the lock's stage. At - /// `Broadcast` (1) the lock isn't usable yet — SPV is waiting + /// Trailing view that depends on the lock's stage. At Broadcast + /// (`!canFundIdentity`) the lock isn't usable yet — SPV is waiting /// on the masternodes to sign an InstantSendLock — so we show /// a spinner instead of a button. SwiftData `@Query` is /// reactive, so when the persister flips the row to - /// `InstantSendLocked` (2) this view re-renders into the - /// Resume button without any extra plumbing. + /// `InstantSendLocked` this view re-renders into the Resume + /// button without any extra plumbing. @ViewBuilder private var trailingAffordance: some View { - if lock.statusRaw >= 2 { + if lock.canFundIdentity { Button(action: onResume) { Label("Resume", systemImage: "arrow.clockwise") .labelStyle(.titleAndIcon) @@ -546,32 +546,8 @@ private struct ResumableRegistrationRow: View { } } - /// Short txid prefix (first 8 hex chars) from the canonical - /// `:` outpoint encoding. Matches the row format - /// used by `AssetLockStorageListView`. - private func shortOutPoint(_ outPointHex: String) -> String { - let parts = outPointHex.split( - separator: ":", - maxSplits: 1, - omittingEmptySubsequences: false - ) - guard parts.count == 2 else { return outPointHex } - let txidPrefix = parts[0].prefix(8) - return "\(txidPrefix):\(parts[1])" - } - private func formatDuffs(_ amountDuffs: Int64) -> String { let dash = Double(amountDuffs) / 1e8 return String(format: "%g DASH", dash) } - - private func statusLabel(_ raw: Int) -> String { - switch raw { - case 0: return "Built" - case 1: return "Broadcast" - case 2: return "InstantSendLocked" - case 3: return "ChainLocked" - default: return "Unknown(\(raw))" - } - } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift new file mode 100644 index 00000000000..f2c049f96bc --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift @@ -0,0 +1,65 @@ +// PersistentAssetLockDisplay.swift +// SwiftExampleApp +// +// Consolidates the asset-lock display properties (status label, +// short outpoint, fundability predicate) that previously lived as +// inline switches and duplicated helpers across several views. +// Keeping them on `PersistentAssetLock` itself means the 0/1/2/3 +// `AssetLockStatus` discriminants — a protocol-mirrored constant +// from the Rust side — exist in exactly one place in the example +// app. If/when we push these to a Rust-derived FFI property on the +// persistent row, this file is the single point to update. + +import Foundation +import SwiftDashSDK + +/// Visibility / actionability predicates over the `AssetLockStatus` +/// discriminants, plus the human-facing label. The predicates are +/// defined on `AssetLockResumeRow` so they're free at every +/// conformer (including `FakeAssetLockRow` in tests). +extension AssetLockResumeRow { + /// `true` when the lock has a usable IS-lock or chain-lock + /// proof. Only these locks can fund a Platform identity right + /// now; the Resumable Registrations row's Resume button gates + /// on this. Built (0) and Broadcast (1) have no signed proof + /// yet — submitting them would fail at the Platform layer. + var canFundIdentity: Bool { statusRaw >= 2 } + + /// `true` when the lock should be surfaced on the Resumable + /// Registrations section at all. Lower bar than `canFundIdentity` + /// — a Broadcast (1) lock isn't actionable yet but the user + /// should see it (as "Waiting for InstantSendLock…") so an + /// in-flight crash-recovery situation has visible continuity + /// through the IS-lock arrival. + var isVisibleAsResumable: Bool { statusRaw >= 1 } +} + +/// Human-readable label for `PersistentAssetLock.statusRaw`. Kept +/// here (rather than as a `case` block re-implemented in every +/// view) so the 0/1/2/3 → label mapping has one home. Mirrors the +/// Rust-side `AssetLockStatus` enum. +extension PersistentAssetLock { + var statusLabel: String { + switch statusRaw { + case 0: return "Built" + case 1: return "Broadcast" + case 2: return "InstantSendLocked" + case 3: return "ChainLocked" + default: return "Unknown(\(statusRaw))" + } + } + + /// First 8 hex chars of the txid plus the vout, derived from + /// the canonical `:` outpoint encoding. Used by + /// every UI that lists asset-lock rows so the txid prefix + /// reads the same way across surfaces. + var shortOutPointDisplay: String { + let parts = outPointHex.split( + separator: ":", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard parts.count == 2 else { return outPointHex } + return "\(parts[0].prefix(8)):\(parts[1])" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 99936466a4c..5bf1dd1631c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -376,7 +376,7 @@ struct CreateIdentityView: View { summaryRow("Wallet", value: walletLabel) summaryRow( "Asset Lock", - value: shortOutPoint(lock.outPointHex), + value: lock.shortOutPointDisplay, monospaced: true ) summaryRow( @@ -388,7 +388,7 @@ struct CreateIdentityView: View { ) summaryRow( "Status", - value: Self.assetLockStatusLabel(rawValue: lock.statusRaw) + value: lock.statusLabel ) summaryRow( "Identity Slot", @@ -429,19 +429,6 @@ struct CreateIdentityView: View { } } - /// First 8 hex chars of the txid plus the vout, derived from the - /// canonical `:` outpoint encoding. Mirrors the row - /// format `ResumableRegistrationRow` uses on the Identities tab - /// so the same lock reads the same way across surfaces. - private func shortOutPoint(_ outPointHex: String) -> String { - let parts = outPointHex.split( - separator: ":", - maxSplits: 1, - omittingEmptySubsequences: false - ) - guard parts.count == 2 else { return outPointHex } - return "\(parts[0].prefix(8)):\(parts[1])" - } @ViewBuilder private func walletAccountSection(for walletId: Data) -> some View { @@ -1489,28 +1476,7 @@ struct CreateIdentityView: View { return (txidRaw, vout) } - /// Human-readable status label for the asset-lock picker row. - /// Mirrors the discriminants in - /// [`PersistentAssetLock.statusRaw`]. - private static func assetLockStatusLabel(rawValue: Int) -> String { - switch rawValue { - case 0: return "Built" - case 1: return "Broadcast" - case 2: return "InstantSend locked" - case 3: return "ChainLock locked" - default: return "Unknown" - } - } - - /// Compact relative date string ("2 minutes ago"). Used by the - /// resume picker so the user can see how recent the lock is. - private static func relativeDateString(_ date: Date) -> String { - let fmt = RelativeDateTimeFormatter() - fmt.unitsStyle = .short - return fmt.localizedString(for: date, relativeTo: Date()) - } - -/// `"0.01 DASH"` — stripped of trailing zeros, uses up to 8 decimals. + /// `"0.01 DASH"` — stripped of trailing zeros, uses up to 8 decimals. private static func formatDash(raw: UInt64, divisor: Double) -> String { let dash = Double(raw) / divisor let fmt = NumberFormatter() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 2aa0f2f4caf..3facdd507ba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1640,7 +1640,7 @@ struct AssetLockStorageListView: View { .font(.system(.caption, design: .monospaced)) .lineLimit(1).truncationMode(.middle) HStack(spacing: 8) { - Text(statusLabel(record.statusRaw)) + Text(record.statusLabel) .font(.caption2) .foregroundColor(.secondary) Spacer() @@ -1664,16 +1664,6 @@ struct AssetLockStorageListView: View { } } } - - private func statusLabel(_ raw: Int) -> String { - switch raw { - case 0: return "Built" - case 1: return "Broadcast" - case 2: return "InstantSendLocked" - case 3: return "ChainLocked" - default: return "Unknown(\(raw))" - } - } } // MARK: - PersistentWalletManagerMetadata diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 7d39c0ad00c..0ae41fc1d40 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1838,7 +1838,7 @@ struct AssetLockStorageDetailView: View { Form { Section("Asset Lock") { FieldRow(label: "Outpoint", value: record.outPointHex) - FieldRow(label: "Status", value: statusLabel(record.statusRaw)) + FieldRow(label: "Status", value: record.statusLabel) FieldRow(label: "Funding Type", value: fundingTypeLabel(record.fundingTypeRaw)) FieldRow(label: "Identity Index", value: "\(record.identityIndexRaw)") FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") @@ -1878,7 +1878,7 @@ struct AssetLockStorageDetailView: View { // status so the entry is self-explanatory. FieldRow( label: pendingLabel(record.statusRaw), - value: statusLabel(record.statusRaw) + value: record.statusLabel ) } } @@ -1899,16 +1899,6 @@ struct AssetLockStorageDetailView: View { .navigationBarTitleDisplayMode(.inline) } - private func statusLabel(_ raw: Int) -> String { - switch raw { - case 0: return "Built" - case 1: return "Broadcast" - case 2: return "InstantSendLocked" - case 3: return "ChainLocked" - default: return "Unknown(\(raw))" - } - } - private func fundingTypeLabel(_ raw: Int) -> String { switch raw { case 0: return "IdentityRegistration" From 218887ca8266c3e2d52474d178985097f9b1f6f9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 17:34:57 +0700 Subject: [PATCH 24/54] =?UTF-8?q?chore:=20review=20nits=20=E2=80=94=20pin?= =?UTF-8?q?=20outpoint=20round-trip,=20Txid::from=5Fbyte=5Farray,=20lifeti?= =?UTF-8?q?me=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small cleanups from the review pass, each a separate small win: 1. rs-platform-wallet-ffi: `Txid::from_slice` -> `Txid::from_byte_array`. `OutPointFFI::txid` is statically `[u8; 32]` so the slice variant's error branch was unreachable. The new call matches the convention used across `rs-drive-abci` and `rs-platform-wallet-ffi/src/persistence.rs`. 2. SwiftExampleAppTests: pin the outpoint hex round-trip (`PersistentAssetLock.encodeOutPoint` <-> `CreateIdentityView.parseOutPointHex`) plus a defensive test that malformed hex inputs return nil instead of producing all-zero bytes. Either side flipping endianness or silently producing zeros would address a different outpoint at the FFI layer and surface as an opaque Platform proof-verification failure. `parseOutPointHex` bumped from `private static` to `static` so @testable can reach it. 3. ManagedPlatformWallet.resumeIdentityWithAssetLock: add a comment pinning the `withExtendedLifetime` invariant. The Rust FFI uses `block_on_worker` synchronously inside the closure, so the resolver pair is alive for the whole call; a future refactor that introduces an unawaited Task inside the closure would drop the resolver mid-flight. Comment makes the invariant explicit. Tests: 12/12 green (was 10/10), 2 new round-trip cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...dentity_registration_funded_with_signer.rs | 17 +++--- .../ManagedPlatformWallet.swift | 8 +++ .../Views/CreateIdentityView.swift | 8 ++- .../CreateIdentityResumableTests.swift | 54 +++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs index d03dba0a900..165e54f186e 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -245,18 +245,15 @@ pub unsafe extern "C" fn platform_wallet_resume_identity_with_existing_asset_loc ); } + // `OutPointFFI::txid` is `[u8; 32]` so the conversion is + // infallible — `from_byte_array` consumes the array directly, + // unlike `from_slice` which would defensively return a `Result` + // for length checking we don't need here. Matches the + // convention already used across `rs-drive-abci` / + // `rs-platform-wallet-ffi/src/persistence.rs`. let out_point_ffi = *out_point; - let txid = match dashcore::Txid::from_slice(&out_point_ffi.txid) { - Ok(t) => t, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("out_point.txid is not a valid 32-byte txid: {e}"), - ); - } - }; let resume_outpoint = dashcore::OutPoint { - txid, + txid: dashcore::Txid::from_byte_array(out_point_ffi.txid), vout: out_point_ffi.vout, }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 5f92f434174..924eea07438 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2498,6 +2498,14 @@ extension ManagedPlatformWallet { } var outPoint = OutPointFFI(txid: txidTuple, vout: outPointVout) let pubkeyBuffers: [Data] = pubkeys.map { $0.pubkeyBytes } + // `withExtendedLifetime` pins `signer` and `coreSigner` + // through the closure body. The FFI call inside is + // synchronous (Rust uses `block_on_worker` under the + // hood), so the closure returns before the lifetime + // wrapper exits — invariant holds. If anyone refactors + // this to spawn an unawaited Task inside, the resolver + // could be dropped mid-flight and Rust would see a + // dangling pointer; keep the FFI call inline. let result = withExtendedLifetime((signer, coreSigner)) { ManagedPlatformWallet.withPubkeyFFIArray( pubkeys, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 5bf1dd1631c..3fd5597687b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -1451,7 +1451,13 @@ struct CreateIdentityView: View { /// endian order — what the FFI's `OutPointFFI.txid` field /// expects. Reverse of `PersistentAssetLock.encodeOutPoint`. /// Returns `nil` on any parse failure. - private static func parseOutPointHex(_ hex: String) -> (Data, UInt32)? { + /// + /// Internal (not private) so the unit test target can pin the + /// round-trip with `encodeOutPoint`. Wrong endianness on either + /// side would silently address a different outpoint and produce + /// confusing Platform-side proof-verification failures, so the + /// round-trip invariant is worth pinning explicitly. + static func parseOutPointHex(_ hex: String) -> (Data, UInt32)? { let parts = hex.split( separator: ":", maxSplits: 1, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index 2d866bb058c..270ac1e9db7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -1,4 +1,5 @@ import XCTest +import SwiftDashSDK @testable import SwiftExampleApp /// Tests the pure anti-join that powers the Identities-tab @@ -212,6 +213,59 @@ final class CreateIdentityResumableTests: XCTestCase { XCTAssertTrue(result.isEmpty) } + // MARK: - outpoint hex round-trip + + /// `PersistentAssetLock.encodeOutPoint` (rawBytes → display + /// hex) and `CreateIdentityView.parseOutPointHex` (display hex + /// → rawBytes) are the two halves of the outpoint round-trip + /// the resume path depends on: the persister writes the + /// display-order hex, the submit path reads it and hands the + /// raw wire-order bytes to the FFI. If either side flips + /// endianness, the resume FFI silently addresses a different + /// outpoint and the Platform proof-verification failure that + /// follows is opaque. Pin the round-trip explicitly. + func testOutPointHexRoundTripPreservesRawBytes() { + // Deliberately asymmetric bytes so any endian flip is + // visible. txid wire-order = little-endian. + let originalTxid = Data([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + ]) + let originalVout: UInt32 = 7 + + // The persister writes the 36-byte (txid_le || vout_le) + // blob — assemble it the same way and encode. + var raw = Data(originalTxid) + withUnsafeBytes(of: originalVout.littleEndian) { raw.append(contentsOf: $0) } + XCTAssertEqual(raw.count, 36) + let hex = PersistentAssetLock.encodeOutPoint(rawBytes: raw) + + // Decode via the submit-path parser. + guard let (rtTxid, rtVout) = CreateIdentityView.parseOutPointHex(hex) else { + XCTFail("parseOutPointHex returned nil for canonical encoding \(hex)") + return + } + XCTAssertEqual(rtTxid, originalTxid, + "txid bytes round-tripped through display-hex must match") + XCTAssertEqual(rtVout, originalVout) + } + + /// Defensive: parseOutPointHex must reject malformed inputs + /// instead of silently producing zeros. Same risk class as the + /// endianness flip — a "valid-looking" but wrong outpoint + /// would fail at the Platform layer with confusing errors. + func testParseOutPointHexRejectsMalformedInputs() { + XCTAssertNil(CreateIdentityView.parseOutPointHex("")) + XCTAssertNil(CreateIdentityView.parseOutPointHex("nope")) + XCTAssertNil(CreateIdentityView.parseOutPointHex("abc:1")) // txid too short + XCTAssertNil(CreateIdentityView.parseOutPointHex( + String(repeating: "a", count: 64) + ":bogus")) // vout NaN + XCTAssertNil(CreateIdentityView.parseOutPointHex( + String(repeating: "g", count: 64) + ":0")) // non-hex chars + } + /// Exhaustive predicate test for which controller phases hold /// the slot. The Resumable surface uses this to decide which /// in-memory controllers block their lock from re-appearing From 46467ec916844a52f5dc8df7f7ef95b04f6b0101 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 17:42:22 +0700 Subject: [PATCH 25/54] docs(swift-sdk): add iter 5 UAT matrix to identity-from-core-funds plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests pin the filter / predicate / round-trip invariants; runtime composition (SwiftData @Query reactivity, coordinator @Published mutations, view re-renders, SPV event routing) needs manual testnet validation. Six scenarios cover the happy path, the 🔴 double-tap-during-in-flight guard, crash recovery from both pre-IS-lock (status 1) and post-IS-lock (status 2/3) states, the failed-retry flow, and the `.completed` retention window. Also documents which upstream PR #3549 issues are tangential to our UAT (#1 / #5: different code paths; #2: mitigated by the persister's proofBytes capture at the IS-lock arrival moment; #3 / #4: doc fixes only). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index d47bd73b9fc..6362c1e855d 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -961,7 +961,39 @@ IS-lock arrival. source" was also conceptually wrong — it's resumption, not funding.)* -3. **Crash-recovery validation**: see Iter 5 follow-up below. +3. **Crash-recovery validation**: see UAT matrix below. + +--- + +### Iter 5 UAT matrix — manual testnet validation + +The unit tests (`CreateIdentityResumableTests`, 12 cases) pin the +filter math, phase predicate, outpoint round-trip, and active-slot +exclusion. Runtime composition — SwiftData `@Query` reactivity + +coordinator `@Published` mutations + view re-renders + SPV event +routing — has to be verified on a real testnet sim/device. + +| # | Scenario | Expected | Verifies | +|---|---|---|---| +| 1 | **Happy path** — Create Identity from wallet balance, complete normally. | No phantom row appears in Resumable during the broadcast → identity-write window. Pending row shows progress, completes, disappears after ~30s retention sweep. | Active-slot anti-join correctness; coordinator retention sweep. | +| 2 | **🔴 bug repro** — Start a fresh registration; switch to Identities tab while it's mid-flight. | The in-flight slot must NOT appear in Resumable Registrations — only in Pending. | The `Phase.isActive` + identity-slot union in `ResumableRegistrationsList` (commit `02a15497c6`). | +| 3 | **Crash recovery, status 1** — Force-quit during Broadcasting (kill app before the IS lock arrives). | On relaunch: Resumable row with spinner + "Waiting for InstantSendLock…". After ~10–30s on testnet the row flips to a Resume button automatically (SPV delivers IS lock → persister bumps status 1→2 → `@Query` re-fires). Tap Resume → completes. | Status-1 visibility floor; SwiftData @Query reactivity on status transition; persister IS-lock callback. | +| 4 | **Crash recovery, status 2/3** — Kill app between IS-lock arrival and Platform submit. | On relaunch: Resume button immediately. Tap → opens `CreateIdentityView` pre-filled (read-only summary) → tap Create Identity → completes. | Path B preselect flow end-to-end; FFI `platform_wallet_resume_identity_with_existing_asset_lock_signer`. | +| 5 | **Failed retry** — Trigger a failure (toggle airplane mode mid-flight, or some other failure injection). | Pending row shows Failed + Dismiss. Tap Dismiss → Resumable row reappears for the same slot → tap Resume → succeeds on retry. | Coordinator `.failed → .preparingKeys` restart path; controller-level `.failed` re-submit allowed; dismiss → dict mutation → re-render. | +| 6 | **`.completed` retention guard** — Wait ~10s after a registration success, return to Identities tab. | Pending row still shows Done. Resumable section does NOT show the same slot (anti-join via `PersistentIdentity` row). Coordinator's retention sweep clears Pending at ~30s. | `.completed` not in `Phase.isActive` (correctly — identity row covers the slot). | + +**Out-of-scope for this UAT (separately scheduled or out of plan):** +- Identity top-up flows — separate code path; affected by upstream + PR #3549 issue 1 (HIGH) which our resume path doesn't exercise. +- Non-BIP44 funding accounts (CoinJoin, BIP-32) — separate code path; + affected by upstream PR #3549 issue 5 (LOW). +- `TransactionContext::InstantSend → InBlock` overwrite (upstream + PR #3549 issue 2, MEDIUM). Our `PersistentAssetLock.proofBytes` + captures the proof at the IS-lock arrival moment so the proof + bytes survive the transaction-record context promotion; if + scenario #3 ever exhibits "tx confirmed but row stuck on + 'Waiting for InstantSendLock…' indefinitely", flag this as the + suspect and verify `proofBytes` was written at status 1→2. --- From 6ce8d8d0c1cbb6fedb2c11d19cb88684bfac4752 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 17:44:59 +0700 Subject: [PATCH 26/54] docs(swift-sdk): note asset-lock TX rendering gap in plan polish list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asset-lock TXs render in the wallet's transaction list as "0 DASH transaction to itself" because the special asset-lock output script doesn't decode as a P2PKH/P2SH destination and the generic row formatter has no asset-lock-aware branch. The funds are correctly accounted (they leave the Core balance), but the row label is misleading. Added to the Polish section as a follow-up — the fix belongs in the TX-list render path, not this branch's funding/resume scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md index 6362c1e855d..7e344eb20bd 100644 --- a/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/CREATE_IDENTITY_FROM_CORE_FUNDS_PLAN.md @@ -504,6 +504,19 @@ is XOR-masked + `memset_s`-zeroed. for test parity with `prePersistIdentityKeysForRegistration` (which already does this). +- **Transaction-list rendering for asset-lock TXs** — the wallet's + transaction list currently renders an asset-lock TX as "0 DASH + transaction to itself" because the special asset-lock output + script doesn't decode as a P2PKH/P2SH destination and the + generic row formatter has no asset-lock-aware branch. The + funds are accounted (they leave the Core balance to fund the + identity), but the row label is misleading. Detect asset-lock + TXs (cross-reference against `PersistentAssetLock.outPointHex` + via the txid) and render them as e.g. "Asset Lock — 0.0025 + DASH locked for identity #N" with a tap-through to the + `AssetLockStorageDetailView` row. Belongs in the same render + path as the existing TX-list view, not in this branch's scope. + #### 📌 Architectural / longer-term - **Adversarial P0 #1** — Wallet-manager write lock is held across From 3a57198fd8960207d6576f8d182c2ba8b7191a1c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 18:05:53 +0700 Subject: [PATCH 27/54] docs(claude): add simulator-inspect skill for SwiftExampleApp state verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user reports a UI symptom in SwiftExampleApp, the fastest way to a real diagnosis is reading the app's actual SwiftData state rather than inferring from screenshots. The app's UI is a @Query-driven projection of the SQLite database in default.store; reading the .store directly gives ground truth in two `sqlite3` queries. Documents the read-only toolkit available via the standard Xcode command-line tools (xcrun simctl + sqlite3 + WebFetch against insight), no extra installs: - Screenshot via `xcrun simctl io booted screenshot` - App data container lookup via `xcrun simctl get_app_container` - SwiftData state read via sqlite3 on the .store file - Pasteboard sync via `xcrun simctl pbcopy/pbpaste` - Log capture via `xcrun simctl spawn booted log {stream,show}` - Deep linking via `xcrun simctl openurl` Includes a schema cheat sheet for the ZPERSISTENT* tables most relevant to this app (AssetLock / Identity / Wallet / Account / Txo / Transaction / PublicKey / Document) and the AssetLockStatus discriminant mapping (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked). Seven workflows are documented with the exact commands: - A: verify a "stuck" asset lock (SwiftData state + interpretation table) - B: cross-check against testnet chain via insight API - C: walk Core Data foreign-key relationships - D: capture a screenshot for visual confirmation - E: stream logs while the user performs an action - F: locate the booted UDID + app bundle id - G: deep-link into a specific screen via URL scheme Includes a worked example from this session: the iter 5 stuck-resume diagnosis (slot #10 status=1, no proof, chain has txlock=true → SPV catch-up gap). Two sqlite3 queries + one WebFetch was enough to go from "the row says 'Waiting…' forever" to "Rust needs a refresh helper on wallet load." Explicitly NOT in scope: tap/swipe/type. Documented as a limitation; the user drives the UI, the skill verifies it. idb is mentioned as the install for full automation if needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/simulator-inspect/SKILL.md | 196 ++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .claude/skills/simulator-inspect/SKILL.md diff --git a/.claude/skills/simulator-inspect/SKILL.md b/.claude/skills/simulator-inspect/SKILL.md new file mode 100644 index 00000000000..5d1a14b144c --- /dev/null +++ b/.claude/skills/simulator-inspect/SKILL.md @@ -0,0 +1,196 @@ +--- +name: simulator-inspect +description: Inspect SwiftExampleApp state directly from the booted iOS simulator — screenshot, read SwiftData, capture logs, look up app data. Use whenever the user reports a UI bug, asks "why is X stuck?", or you need to verify the app's actual persisted state vs. what the user sees. Read-only: this skill does NOT tap, swipe, or type — the user drives the UI, you verify it. +argument-hint: "[lock-slot | identity-index | wallet | full]" +--- + +# Simulator Inspect — SwiftExampleApp ground-truth verification + +When a user reports a UI symptom in SwiftExampleApp ("the row is stuck on Waiting…", "my balance didn't update", "the registration spinner never finishes"), the fastest way to a real diagnosis is reading the app's **actual SwiftData state** rather than relying on screenshots alone. The app's UI is a `@Query`-driven projection of the SQLite database backing `default.store`; if a row says `statusRaw = 1`, the UI shows "Broadcast" — full stop, no caching weirdness. + +This skill exposes the toolkit Claude can use without any extra installs (`idb`, Appium, etc. are NOT required). All commands are read-only or screenshot-only. + +## When to use + +- User reports an asset lock is stuck mid-flight +- User asks to verify what's in SwiftData for an identity / wallet / lock +- You're hypothesizing about UI vs. state divergence and need ground truth +- You want a screenshot of the current screen the user is on +- You need the full txid / outpoint / id of something the UI is truncating +- You're testing a fix and want to confirm the persister wrote what you expect + +## When NOT to use + +- You need to tap a button, swipe, or type text. **You can't from here.** Ask the user. +- You need to drive a full UAT cycle autonomously. Use a UI-automation tool like `idb` instead (requires install) or have the user execute the matrix. + +## Quick reference + +All commands resolve the booted device automatically. The app bundle id is `org.dashfoundation.SwiftExampleApp` (the SwiftExampleApp in `packages/swift-sdk/SwiftExampleApp`). + +```bash +# 1. Screenshot the current sim screen +xcrun simctl io booted screenshot /tmp/sim.png + +# 2. Find the SwiftData store path (data container changes per build) +DATA_DIR=$(xcrun simctl get_app_container booted org.dashfoundation.SwiftExampleApp data) +STORE="$DATA_DIR/Library/Application Support/default.store" + +# 3. Read SwiftData +sqlite3 "$STORE" -header -column "SELECT ..." + +# 4. Stream logs (warn: 'getpwuid_r' noise on stderr is harmless) +xcrun simctl spawn booted log show --last 60s --info \ + --predicate 'processImagePath CONTAINS "SwiftExampleApp"' +``` + +## Schema cheat sheet (read-only) + +SwiftData persists everything under `default.store` with `Z`-prefixed Core Data column names. The full table list comes from `sqlite3 "$STORE" ".tables"`. The most common ones for this app: + +| Table | Key columns | Purpose | +|---|---|---| +| `ZPERSISTENTASSETLOCK` | `ZSTATUSRAW`, `ZIDENTITYINDEXRAW`, `ZOUTPOINTHEX`, `ZPROOFBYTES`, `ZWALLETID` | Tracked asset locks for identity funding | +| `ZPERSISTENTIDENTITY` | `ZIDENTITYINDEX`, `ZIDENTITYID`, `ZNETWORKRAW`, `ZWALLET` | Registered platform identities | +| `ZPERSISTENTWALLET` | `ZWALLETID`, `ZLABEL`, `ZNETWORKRAW` | Local wallets | +| `ZPERSISTENTACCOUNT` | `ZACCOUNTTYPE`, `ZWALLET` | Per-wallet accounts (BIP44 / Platform Payment etc.) | +| `ZPERSISTENTTXO` | `ZWALLETID`, `ZTRANSACTION`, `ZSPENDINGTRANSACTION` | UTXOs, source of `TransactionListView` | +| `ZPERSISTENTTRANSACTION` | `ZTXID`, `ZCONTEXT`, `ZFIRSTSEEN`, `ZBLOCKHEIGHT` | Confirmed/mempool TXs | +| `ZPERSISTENTPUBLICKEY` | `ZKEYINDEX`, `ZIDENTITY` | Identity pubkeys | +| `ZPERSISTENTDOCUMENT` | `ZDOCUMENTID`, `ZDATACONTRACT` | Persisted documents | + +`ZSTATUSRAW` values on asset lock: `0`=Built, `1`=Broadcast, `2`=InstantSendLocked, `3`=ChainLocked. (Mirror of Rust `AssetLockStatus`.) + +`ZCONTEXT` on transaction: `0`=mempool, `1`=instantSend, `2`=inBlock, `3`=inChainLockedBlock. + +## Common workflows + +### Workflow A — Verify an asset lock the user says is "stuck" + +```bash +DATA_DIR=$(xcrun simctl get_app_container booted org.dashfoundation.SwiftExampleApp data) +STORE="$DATA_DIR/Library/Application Support/default.store" + +# Replace 10 with the slot the user mentioned (visible as "Identity Index" in the UI). +sqlite3 "$STORE" -header -column " +SELECT ZIDENTITYINDEXRAW AS slot, + ZSTATUSRAW AS status, + ZAMOUNTDUFFS AS duffs, + length(ZPROOFBYTES) AS proof_len, + length(ZTRANSACTIONBYTES) AS tx_len, + ZOUTPOINTHEX + FROM ZPERSISTENTASSETLOCK + WHERE ZIDENTITYINDEXRAW = 10;" +``` + +Interpretation: + +| `status` | `proof_len` | Meaning | +|---|---|---| +| 1 | NULL/empty | Broadcast, waiting for IS lock — normal if recent, suspicious if old (event-vs-poll gap) | +| 2 or 3 | non-null | Resumable. UI should show a Resume button. | +| 1+ | non-null | **Inconsistency** — write ordering bug. | +| 0 | anything | Built but never broadcast — tight crash window. | + +The full `ZOUTPOINTHEX` value (which the UI truncates to a `780ea99…257d0:0` prefix) is the round-trip-able outpoint you can paste into a testnet explorer. Strip the `:VOUT` suffix to get the txid. + +### Workflow B — Cross-check against testnet chain state + +```bash +# Use the txid (first 64 hex chars of ZOUTPOINTHEX, before the ':') against insight +TXID=780ea9931eae9d4e6a0df2c0c2721c11bd645fc453fb2907b4a4894893a257d0 +curl -s "https://insight.testnet.networks.dash.org/insight-api/tx/$TXID" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); \ + print(f'block: {d.get(\"blockheight\")}, confirmations: {d.get(\"confirmations\")}, txlock: {d.get(\"txlock\")}')" +``` + +Or use `WebFetch` against the same URL with a prompt asking for confirmations / `txlock` / `chainlocked`. + +Diagnostic table for `(SwiftData state, chain state)` → root cause: + +| SwiftData says | On chain | Diagnosis | +|---|---|---| +| status 1, no proof | mined + chainlocked | **SPV catch-up gap** — signatures exist but our wallet hasn't backfilled them. Needs Rust-side refresh helper. | +| status 1, no proof | mined, no chainlock | Pure timing — waiting for masternodes. Not a bug if recent. | +| status 1, no proof | not found / not mined | Broadcast never confirmed. TX may have been dropped. | +| status 2/3, proof present, but UI shows "Waiting…" | anything | **UI reactivity bug** — `@Query` not picking up the row. | +| status 2/3, proof present | TX not chainlocked yet | Fine — IS-lock proof is sufficient for Platform submit. | + +### Workflow C — Find which identity/document/contract a SwiftData row belongs to + +The Z-prefixed schema is Core Data conventions; relationship columns are integer foreign keys to the related table's `Z_PK`. Join shape: + +```bash +sqlite3 "$STORE" " +SELECT i.ZIDENTITYINDEX, hex(i.ZIDENTITYID), w.ZLABEL + FROM ZPERSISTENTIDENTITY i + LEFT JOIN ZPERSISTENTWALLET w ON i.ZWALLET = w.Z_PK + WHERE i.ZIDENTITYINDEX = 10;" +``` + +### Workflow D — Get a screenshot for visual confirmation + +```bash +xcrun simctl io booted screenshot /tmp/sim.png +# Then use the Read tool on /tmp/sim.png — Claude can view the image inline. +``` + +Pair with `xcrun simctl status_bar booted override --time "9:41"` first if you want the canonical clean-status-bar Apple marketing-shot look. Reset with `xcrun simctl status_bar booted clear`. + +### Workflow E — Stream logs while the user does an action + +```bash +# Foreground capture for ~30s (run in background, then read the file) +xcrun simctl spawn booted log show --last 30s --info \ + --predicate 'processImagePath CONTAINS "SwiftExampleApp"' > /tmp/applog.txt 2>&1 +``` + +For long-running streams, use `log stream` instead and redirect to a file; let the user perform the action; then `cat` the file. Note that the example app uses plain `print` / `os_log` mostly without a dedicated subsystem identifier, so `processImagePath` is a more reliable filter than `subsystem`. + +### Workflow F — Locate the booted UDID / app bundle + +```bash +# UDID of the booted device (one of them, if multiple) +xcrun simctl list devices booted + +# All installed apps with bundle ids +xcrun simctl listapps booted | grep -E "CFBundleIdentifier|CFBundleName" + +# Just our app +xcrun simctl listapps booted | grep -B1 -A6 SwiftExample +``` + +### Workflow G — Reproduce a deep-link path + +If the app exposes a custom URL scheme: +```bash +xcrun simctl openurl booted "dashplatform://identity/abc123" +``` + +(SwiftExampleApp doesn't currently register a URL scheme, but this is the path if one is added — useful for jumping directly to a specific screen during testing.) + +## Pitfalls + +- **Data container changes per install.** Always look it up via `get_app_container` — don't hardcode the UUID in any path. +- **`default.store-wal` and `-shm` files** are the SQLite write-ahead log and shared memory; don't move/delete them while the app is running, or you'll corrupt the journal. Reading the `.store` directly is fine (SQLite handles it). +- **Multiple booted simulators** — `booted` picks one. If the user has multiple, ask which device or pass the UDID explicitly. +- **`getpwuid_r did not find a match for uid 502`** on log commands is a harmless stderr warning; the logs still stream. +- **Status changes during read** are not atomic vs. the SwiftData write — if you read mid-flight you may see status=1 one query and status=2 the next. For diagnostics that's fine; if it bothers you, take two reads 1s apart. +- **Z_PK foreign keys are NOT stable across re-installs** — they're integer primary keys. Don't quote them in long-lived issue reports; quote `ZIDENTITYID` / `ZOUTPOINTHEX` / `ZWALLETID` blobs instead. + +## What this skill does NOT do + +- **Tap, swipe, scroll, type.** Use the user, or install `idb` (`brew install facebook/fb/idb-companion && pip install fb-idb`) and document the dependency. +- **Modify state.** Writing to the SwiftData store from outside the app while the app is running is unsafe. To force a state change, use the app's UI or restart the app under controlled conditions. +- **Mock the network / chain state.** For that you need testnet faucets, regtest, or fixture-based tests at the Rust layer. + +## Worked example — the iter 5 stuck-resume diagnosis (2026-05-13) + +User reported: identity slot #10 stuck on "Waiting for InstantSendLock…" forever. We used: + +1. **Workflow A**: read `ZPERSISTENTASSETLOCK` for slot 10 → status=1, proof_len=NULL, tx_len=240 bytes, full outpoint `780ea9931eae9d4e6a0df2c0c2721c11bd645fc453fb2907b4a4894893a257d0:0`. +2. **Workflow B**: WebFetch to insight.testnet → block 1475917, 67 confirmations, `txlock: true`. +3. **Diagnosis from the table**: row 1 — SPV catch-up gap. IS-lock signature exists on chain but our wallet didn't backfill it on app load. +4. **Root-cause fix scoped**: add `IdentityWallet::refresh_asset_lock_proof_state(outpoint)` in `rs-platform-wallet` that polls SPV's local store, call it at wallet load + inside `wait_for_proof` on entry. + +That whole diagnosis took two `sqlite3` queries + one `WebFetch`. No taps required, no screenshots beyond the initial "user shared screen", no guessing. From cf42ac5afe86d445b5dcf5af1b6bab8f596354b2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 13 May 2026 18:15:56 +0700 Subject: [PATCH 28/54] =?UTF-8?q?docs(claude):=20expand=20simulator-contro?= =?UTF-8?q?l=20skill=20=E2=80=94=20add=20idb=20UI=20automation=20(tap/swip?= =?UTF-8?q?e/type)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames simulator-inspect to simulator-control to reflect the expanded scope: now covers full UI automation (tap, swipe, type, hardware buttons, accessibility tree dump) in addition to the existing inspection toolkit (screenshot, SwiftData read, log capture, chain cross-check). Adds idb (Facebook's iOS Development Bridge) as a required dependency: - brew install facebook/fb/idb-companion (Swift daemon, 1.1.8) - pipx install --python /opt/homebrew/bin/python3.12 fb-idb (Python CLI, 1.1.7) The Python 3.12 pin is load-bearing: fb-idb 1.1.7 uses asyncio.get_event_loop() which Python 3.14 dropped — installing under default Homebrew Python yields "RuntimeError: There is no current event loop". Documented as a pitfall. Killer feature documented: label-find-then-tap pattern. Instead of hardcoded pixel coordinates, dump the accessibility tree (`idb ui describe-all`), filter by AXLabel or AXUniqueId, and tap the frame center. Robust across iPhone models, orientations, and SwiftUI layout tweaks. Includes a `tap_label` shell function in the skill that handles exact + substring matching with focus + enabled filtering. Nine workflows documented end-to-end with verified commands: - A: stuck-asset-lock diagnostic (SwiftData + insight cross-check) - B: full UAT scenario (terminate / launch / tap-label sequence) - C: tap-by-substring (e.g. matching an outpoint prefix) - D: describe-point for hit-test debugging - E: TextField input + return key - F: hardware buttons (HOME, LOCK, SIRI, SIDE_BUTTON) - G: screenshot-diff for state-change verification - H: log capture during an action - I: poll-and-wait for a state transition Worked example: the iter 5 stuck-resume diagnosis from this session, where we used idb to navigate Back -> screenshot the full asset-lock list (revealing that slot #10 is the only one stuck on Broadcast, all 8 others reached InstantSendLocked) -> tap-by-substring to restore the user's screen. Five minutes of automated control instead of screenshot squinting. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/simulator-control/SKILL.md | 279 ++++++++++++++++++++++ .claude/skills/simulator-inspect/SKILL.md | 196 --------------- 2 files changed, 279 insertions(+), 196 deletions(-) create mode 100644 .claude/skills/simulator-control/SKILL.md delete mode 100644 .claude/skills/simulator-inspect/SKILL.md diff --git a/.claude/skills/simulator-control/SKILL.md b/.claude/skills/simulator-control/SKILL.md new file mode 100644 index 00000000000..3feb2b5814d --- /dev/null +++ b/.claude/skills/simulator-control/SKILL.md @@ -0,0 +1,279 @@ +--- +name: simulator-control +description: Drive and inspect SwiftExampleApp on the booted iOS simulator end-to-end — tap, swipe, type, screenshot, read SwiftData, stream logs, dump the accessibility tree. Use when the user reports a UI bug, asks "why is X stuck?", wants a UAT run automated, or you need to verify the app's persisted state against what the UI shows. Covers both inspection (read-only via SwiftData + screenshots) AND control (UI automation via idb). +argument-hint: "[describe | screenshot | tap-label