From 629ff4fd91e680fc771b4cbf459e1bbd6cc2606d Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 3 Feb 2026 14:42:19 -0600 Subject: [PATCH 01/13] feat: add key exchange protocol and state transition signing Add YAPPR key exchange flow for DashPay, QR scanner improvements, state transition signing screen, and refactor UI helpers. Co-Authored-By: Claude Opus 4.5 --- src/backend_task/core/mod.rs | 7 +- src/backend_task/dashpay.rs | 10 + src/backend_task/dashpay/key_exchange.rs | 695 ++++++++++++++ src/backend_task/mod.rs | 23 + .../sign_and_broadcast_state_transition.rs | 36 + src/model/key_exchange_request.rs | 469 +++++++++ src/model/mod.rs | 2 + .../encrypted_key_storage.rs | 10 +- src/model/qualified_identity/mod.rs | 4 +- src/model/state_transition_request.rs | 546 +++++++++++ src/ui/dashpay/dashpay_screen.rs | 8 +- src/ui/dashpay/key_exchange_confirmation.rs | 563 +++++++++++ src/ui/dashpay/mod.rs | 1 + src/ui/dashpay/qr_scanner.rs | 110 ++- src/ui/helpers.rs | 444 +++++---- src/ui/mod.rs | 55 ++ src/ui/tools/mod.rs | 1 + .../tools/state_transition_signing_screen.rs | 897 ++++++++++++++++++ 18 files changed, 3675 insertions(+), 206 deletions(-) create mode 100644 src/backend_task/dashpay/key_exchange.rs create mode 100644 src/backend_task/sign_and_broadcast_state_transition.rs create mode 100644 src/model/key_exchange_request.rs create mode 100644 src/model/state_transition_request.rs create mode 100644 src/ui/dashpay/key_exchange_confirmation.rs create mode 100644 src/ui/tools/state_transition_signing_screen.rs diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index bdad43d1f..b9be7d2e3 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -483,7 +483,12 @@ impl AppContext { const FALLBACK_STEP: u64 = 100; let network = self.wallet_network_key(); - let current_height = wm.current_height(); + let current_height = self + .spv_manager() + .status() + .sync_progress + .map(|p| p.header_height) + .unwrap_or(0); let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); let mut scale_factor = 1.0f64; let mut attempted_fallback = false; diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 982c21a53..4bbb246c7 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -15,12 +15,14 @@ pub mod encryption_tests; pub mod errors; pub mod hd_derivation; pub mod incoming_payments; +pub mod key_exchange; pub mod payments; pub mod profile; pub mod validation; pub use contacts::ContactData; +use crate::model::key_exchange_request::KeyExchangeRequest; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::platform::{Identifier, IdentityPublicKey}; @@ -90,6 +92,11 @@ pub enum DashPayTask { RegisterDashPayAddresses { identity: QualifiedIdentity, }, + /// Handle a key exchange request (YAPPR protocol) + HandleKeyExchangeRequest { + identity: QualifiedIdentity, + request: KeyExchangeRequest, + }, } impl AppContext { @@ -233,6 +240,9 @@ impl AppContext { } ))) } + DashPayTask::HandleKeyExchangeRequest { identity, request } => { + key_exchange::handle_key_exchange_request(self, sdk, &identity, &request).await + } } } } diff --git a/src/backend_task/dashpay/key_exchange.rs b/src/backend_task/dashpay/key_exchange.rs new file mode 100644 index 000000000..9f85f05ed --- /dev/null +++ b/src/backend_task/dashpay/key_exchange.rs @@ -0,0 +1,695 @@ +//! Key Exchange Protocol (YAPPR) implementation +//! +//! This module implements the Dash Platform Application Key Exchange Protocol, +//! enabling web apps to securely obtain deterministic login keys from the wallet. +//! +//! Protocol flow: +//! 1. Web app generates ephemeral keypair and displays `dash-key:` URI +//! 2. User pastes URI into wallet, selects identity, approves +//! 3. Wallet derives login key (BIP32 + HKDF), encrypts with ECDH, publishes to Platform +//! 4. Web app polls for response document, decrypts login key + +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::key_exchange_request::KeyExchangeRequest; +use crate::model::qualified_identity::QualifiedIdentity; +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit}, +}; +use bip39::rand::{RngCore, SeedableRng, rngs::StdRng}; +use dash_sdk::Sdk; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::document::{Document as DppDocument, DocumentV0}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use dash_sdk::dpp::platform_value::{Bytes32, Value}; +use dash_sdk::platform::Identifier; +use hkdf::Hkdf; +use sha2::Sha256; +use std::collections::{BTreeMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; + +/// Feature index for key exchange (m/9'/coin_type'/21'/...) +const KEY_EXCHANGE_FEATURE: u32 = 21; + +/// HKDF salt for deriving the shared secret +const ECDH_HKDF_SALT: &[u8] = b"dash:key-exchange:v1"; + +/// AES-GCM nonce size in bytes +const NONCE_SIZE: usize = 12; + +/// AES-GCM tag size in bytes +#[allow(dead_code)] +const TAG_SIZE: usize = 16; + +/// Handle a key exchange request from the user. +/// +/// This is the main entry point called when the user approves a key exchange request. +/// It: +/// 1. Derives the deterministic login key for the contract +/// 2. Generates an ephemeral keypair for ECDH +/// 3. Encrypts the login key with the shared secret +/// 4. Publishes the response document to Platform +pub async fn handle_key_exchange_request( + app_context: &Arc, + sdk: &Sdk, + identity: &QualifiedIdentity, + request: &KeyExchangeRequest, +) -> Result { + // Get wallet seed for key derivation + let wallet_seed = get_wallet_seed(identity)?; + + // Derive the login key + let login_key = derive_login_key( + &wallet_seed, + app_context.network, + &identity.identity.id(), + &request.contract_id, + request.key_index, + )?; + + // Generate wallet's ephemeral keypair for ECDH + let (wallet_ephemeral_priv, wallet_ephemeral_pub) = generate_ephemeral_keypair()?; + + // Parse app's ephemeral public key + let app_ephemeral_pub = PublicKey::from_slice(&request.app_ephemeral_pub_key) + .map_err(|e| format!("Invalid app ephemeral public key: {}", e))?; + + // Derive ECDH shared secret + let shared_secret = ecdh_shared_secret(&wallet_ephemeral_priv, &app_ephemeral_pub)?; + + // Encrypt the login key + let encrypted_payload = encrypt_login_key(&login_key, &shared_secret)?; + + // Publish the response document + publish_login_key_response( + app_context, + sdk, + identity, + &request.contract_id, + request.key_index, + &wallet_ephemeral_pub.serialize(), + &encrypted_payload, + ) + .await?; + + Ok(BackendTaskSuccessResult::KeyExchangeComplete { + contract_id: request.contract_id, + app_label: request.label.clone(), + }) +} + +/// Get the wallet seed for HD derivation. +fn get_wallet_seed(identity: &QualifiedIdentity) -> Result, String> { + // Use ENCRYPTION key for derivation (matches DashPay pattern) + let encryption_key = identity + .identity + .get_first_public_key_matching( + Purpose::ENCRYPTION, + HashSet::from([SecurityLevel::MEDIUM]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or("No suitable ENCRYPTION key found for key derivation")?; + + let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); + + let seed = identity + .private_keys + .get_resolve( + &( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + encryption_key.id(), + ), + &wallets, + identity.network, + ) + .map_err(|e| format!("Error resolving wallet seed: {}", e))? + .map(|(_, private_key)| private_key) + .ok_or("Wallet seed not found")?; + + Ok(seed.to_vec()) +} + +/// Derive the deterministic login key for a contract. +/// +/// Derivation path: m/9'/coin_type'/21'/account' +/// Then HKDF with identity_id as salt and contract_id || key_index as info +/// +/// Note: coin_type = 5 for mainnet, 1 for testnet/devnet +pub fn derive_login_key( + wallet_seed: &[u8], + network: Network, + identity_id: &Identifier, + contract_id: &Identifier, + key_index: u32, +) -> Result<[u8; 32], String> { + // Determine coin type based on network + let coin_type = match network { + Network::Dash => 5, + Network::Testnet | Network::Devnet | Network::Regtest => 1, + _ => return Err(format!("Unsupported network: {:?}", network)), + }; + + // Build derivation path: m/9'/coin_type'/21'/0' (account 0) + let path = + DerivationPath::from_str(&format!("m/9'/{}'/{}'/0'", coin_type, KEY_EXCHANGE_FEATURE)) + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + // Create master key from seed + let master_key = ExtendedPrivKey::new_master(network, wallet_seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Derive the base key + let secp = Secp256k1::new(); + let base_key = master_key + .derive_priv(&secp, &path) + .map_err(|e| format!("Failed to derive base key: {}", e))?; + + // Extract the private key bytes + let base_private_key = base_key.private_key.secret_bytes(); + + // Build HKDF info: contract_id (32 bytes) || key_index (4 bytes LE) + let mut info = Vec::with_capacity(36); + info.extend_from_slice(&contract_id.to_buffer()); + info.extend_from_slice(&key_index.to_le_bytes()); + + // Use identity_id as salt + let salt = identity_id.to_buffer(); + + // Derive the login key using HKDF-SHA256 + let hkdf = Hkdf::::new(Some(&salt), &base_private_key); + let mut login_key = [0u8; 32]; + hkdf.expand(&info, &mut login_key) + .map_err(|_| "HKDF expansion failed")?; + + Ok(login_key) +} + +/// Generate a fresh ephemeral keypair for ECDH. +pub fn generate_ephemeral_keypair() -> Result<(SecretKey, PublicKey), String> { + let secp = Secp256k1::new(); + let mut rng = StdRng::from_entropy(); + + // Generate random 32 bytes for the private key + let mut key_bytes = [0u8; 32]; + rng.fill_bytes(&mut key_bytes); + + let secret_key = SecretKey::from_slice(&key_bytes) + .map_err(|e| format!("Failed to create secret key: {}", e))?; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + Ok((secret_key, public_key)) +} + +/// Derive ECDH shared secret from private key and public key. +/// +/// Uses HKDF to derive a 32-byte shared secret suitable for AES-256-GCM. +pub fn ecdh_shared_secret( + private_key: &SecretKey, + public_key: &PublicKey, +) -> Result<[u8; 32], String> { + // Perform ECDH multiplication + let shared_point = + dash_sdk::dpp::dashcore::secp256k1::ecdh::shared_secret_point(public_key, private_key); + + // Extract x coordinate (first 32 bytes) + let shared_x = &shared_point[..32]; + + // Derive the shared secret using HKDF + let hkdf = Hkdf::::new(Some(ECDH_HKDF_SALT), shared_x); + let mut shared_secret = [0u8; 32]; + hkdf.expand(&[], &mut shared_secret) + .map_err(|_| "HKDF expansion failed for shared secret")?; + + Ok(shared_secret) +} + +/// Encrypt the login key using AES-256-GCM. +/// +/// Returns: nonce (12 bytes) || ciphertext || tag (16 bytes) +pub fn encrypt_login_key( + login_key: &[u8; 32], + shared_secret: &[u8; 32], +) -> Result, String> { + // Create AES-256-GCM cipher + let cipher = Aes256Gcm::new_from_slice(shared_secret) + .map_err(|e| format!("Failed to create cipher: {}", e))?; + + // Generate random nonce + let mut rng = StdRng::from_entropy(); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the login key + let ciphertext = cipher + .encrypt(nonce, login_key.as_ref()) + .map_err(|e| format!("Encryption failed: {}", e))?; + + // Build output: nonce || ciphertext (includes tag) + let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext); + + Ok(result) +} + +/// Publish the login key response document to Platform. +/// +/// Document type: loginKeyResponse on the keyExchange contract +/// Index: unique on ($ownerId, contractId) +/// +/// Per YAPPR spec Section 7.3, this function checks for an existing document +/// and updates it if found, or creates a new one if not. +#[allow(clippy::too_many_arguments)] +async fn publish_login_key_response( + app_context: &Arc, + sdk: &Sdk, + identity: &QualifiedIdentity, + target_contract_id: &Identifier, + key_index: u32, + wallet_ephemeral_pub_key: &[u8; 33], + encrypted_payload: &[u8], +) -> Result<(), String> { + use dash_sdk::dpp::document::DocumentV0Setters; + use dash_sdk::drive::query::{WhereClause, WhereOperator}; + use dash_sdk::platform::{DataContract, Document, DocumentQuery, Fetch, FetchMany}; + + // Get the key exchange contract ID + let contract_id = app_context + .key_exchange_contract_id() + .ok_or("Key exchange contract ID not configured")?; + + tracing::info!( + "Fetching key exchange contract: {}", + contract_id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ); + + // Fetch the key exchange contract from the network + let key_exchange_contract = DataContract::fetch(sdk, contract_id) + .await + .map_err(|e| format!("Failed to fetch key exchange contract: {}", e))? + .ok_or("Key exchange contract not found on network")?; + + // Store the contract in the database so the SDK's context provider can find it + // during document validation + app_context + .db + .insert_contract_if_not_exists( + &key_exchange_contract, + Some("keyExchange"), + crate::database::contracts::InsertTokensToo::NoTokensShouldBeAdded, + app_context, + ) + .map_err(|e| format!("Failed to store key exchange contract: {}", e))?; + + // Log contract details for debugging + tracing::info!( + "Fetched contract ID: {}, document types: {:?}", + key_exchange_contract + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + key_exchange_contract + .document_types() + .keys() + .collect::>() + ); + + let key_exchange_contract = Arc::new(key_exchange_contract); + + // Get signing key + let signing_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or("No suitable signing key found")? + .clone(); + + // Helper to query for existing document + let query_existing = || async { + let mut query = DocumentQuery::new(key_exchange_contract.clone(), "loginKeyResponse") + .map_err(|e| format!("Failed to create query: {}", e))?; + + query = query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity.identity.id().to_buffer()), + }) + .with_where(WhereClause { + field: "contractId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(target_contract_id.to_buffer()), + }); + query.limit = 1; + + let existing_docs = Document::fetch_many(sdk, query) + .await + .map_err(|e| format!("Error checking for existing response: {}", e))?; + + Ok::<_, String>(existing_docs.into_iter().find_map(|(_, doc)| doc)) + }; + + // Try to update with retry on revision conflict + const MAX_RETRIES: u32 = 3; + let mut last_error: Option = None; + + for attempt in 1..=MAX_RETRIES { + // Query for existing document (fresh query each attempt) + let existing_doc = query_existing().await?; + + if let Some(mut doc) = existing_doc { + // Update existing document + use dash_sdk::dpp::document::DocumentV0Getters; + + let current_revision = doc.revision(); + tracing::info!( + "Attempt {}/{}: Found existing loginKeyResponse document with revision {:?}, updating it", + attempt, + MAX_RETRIES, + current_revision + ); + + // Update properties + doc.set( + "walletEphemeralPubKey", + Value::Bytes(wallet_ephemeral_pub_key.to_vec()), + ); + doc.set("encryptedPayload", Value::Bytes(encrypted_payload.to_vec())); + doc.set("keyIndex", Value::U32(key_index)); + + // Bump revision - this should increment by 1 + doc.bump_revision(); + + let new_revision = doc.revision(); + tracing::info!("After bump_revision: revision is now {:?}", new_revision); + + // Create replacement transition + use dash_sdk::platform::documents::transitions::DocumentReplaceTransitionBuilder; + let mut builder = DocumentReplaceTransitionBuilder::new( + key_exchange_contract.clone(), + "loginKeyResponse".to_string(), + doc, + ); + + // Add state transition options if available + if let Some(options) = app_context.state_transition_options() { + builder = builder.with_state_transition_creation_options(options); + } + + // Submit to Platform + match sdk.document_replace(builder, &signing_key, identity).await { + Ok(_) => { + tracing::info!( + "Updated key exchange response for contract {}", + target_contract_id.to_string( + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + ) + ); + return Ok(()); + } + Err(e) => { + let error_str = e.to_string(); + // Check if this is a revision conflict error + if error_str.contains("invalid revision") + || error_str.contains("desired revision") + { + tracing::warn!( + "Attempt {}/{}: Revision conflict, will retry: {}", + attempt, + MAX_RETRIES, + error_str + ); + last_error = Some(error_str); + // Small delay before retry to allow Platform state to settle + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + continue; + } else { + return Err(format!("Failed to update key exchange response: {}", e)); + } + } + } + } else { + // No existing document - create new one + tracing::info!("No existing loginKeyResponse document found, creating new one"); + + // Build document properties + let mut properties = BTreeMap::new(); + properties.insert( + "contractId".to_string(), + Value::Identifier(target_contract_id.to_buffer()), + ); + properties.insert( + "walletEphemeralPubKey".to_string(), + Value::Bytes(wallet_ephemeral_pub_key.to_vec()), + ); + properties.insert( + "encryptedPayload".to_string(), + Value::Bytes(encrypted_payload.to_vec()), + ); + properties.insert("keyIndex".to_string(), Value::U32(key_index)); + + // Generate entropy for document ID + let mut rng = StdRng::from_entropy(); + let entropy = Bytes32::random_with_rng(&mut rng); + + // Generate document ID + let document_id = Document::generate_document_id_v0( + &key_exchange_contract.id(), + &identity.identity.id(), + "loginKeyResponse", + entropy.as_slice(), + ); + + // Create the document + let document = DppDocument::V0(DocumentV0 { + id: document_id, + owner_id: identity.identity.id(), + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }); + + // Build and submit the document + let mut builder = + dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder::new( + key_exchange_contract.clone(), + "loginKeyResponse".to_string(), + document, + entropy + .as_slice() + .try_into() + .expect("entropy should be 32 bytes"), + ); + + // Add state transition options if available + if let Some(options) = app_context.state_transition_options() { + builder = builder.with_state_transition_creation_options(options); + } + + // Submit to Platform + sdk.document_create(builder, &signing_key, identity) + .await + .map_err(|e| format!("Failed to publish key exchange response: {}", e))?; + + tracing::info!( + "Published new key exchange response for contract {}", + target_contract_id + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ); + + return Ok(()); + } + } + + // If we get here, all retries failed + Err(last_error + .unwrap_or_else(|| "Failed to update key exchange response after retries".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_derive_login_key_deterministic() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + // Derive twice with same inputs + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + + assert_eq!(key1, key2, "Same inputs should produce same key"); + } + + #[test] + fn test_derive_login_key_different_contracts() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id_1 = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + let contract_id_2 = Identifier::from_bytes(&[0x33u8; 32]).unwrap(); + + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id_1, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id_2, 0) + .expect("Should derive"); + + assert_ne!( + key1, key2, + "Different contracts should produce different keys" + ); + } + + #[test] + fn test_derive_login_key_different_indices() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 1) + .expect("Should derive"); + + assert_ne!( + key1, key2, + "Different indices should produce different keys" + ); + } + + #[test] + fn test_derive_login_key_different_identities() { + let seed = [0x42u8; 64]; + let identity_id_1 = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let identity_id_2 = Identifier::from_bytes(&[0x44u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id_1, &contract_id, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id_2, &contract_id, 0) + .expect("Should derive"); + + assert_ne!( + key1, key2, + "Different identities should produce different keys" + ); + } + + #[test] + fn test_generate_ephemeral_keypair() { + let (priv1, pub1) = generate_ephemeral_keypair().expect("Should generate"); + let (priv2, pub2) = generate_ephemeral_keypair().expect("Should generate"); + + // Verify they're different (random) + assert_ne!( + priv1.secret_bytes(), + priv2.secret_bytes(), + "Different calls should produce different keys" + ); + assert_ne!( + pub1.serialize(), + pub2.serialize(), + "Different calls should produce different public keys" + ); + + // Verify public key is compressed (33 bytes starting with 02 or 03) + let pub_bytes = pub1.serialize(); + assert_eq!(pub_bytes.len(), 33); + assert!(pub_bytes[0] == 0x02 || pub_bytes[0] == 0x03); + } + + #[test] + fn test_ecdh_shared_secret() { + let (priv_a, pub_a) = generate_ephemeral_keypair().expect("Should generate"); + let (priv_b, pub_b) = generate_ephemeral_keypair().expect("Should generate"); + + // Both parties should derive the same shared secret + let secret_a = ecdh_shared_secret(&priv_a, &pub_b).expect("Should derive"); + let secret_b = ecdh_shared_secret(&priv_b, &pub_a).expect("Should derive"); + + assert_eq!(secret_a, secret_b, "ECDH should produce same shared secret"); + } + + #[test] + fn test_encrypt_login_key() { + let login_key = [0xAB; 32]; + let shared_secret = [0xCD; 32]; + + let encrypted = encrypt_login_key(&login_key, &shared_secret).expect("Should encrypt"); + + // Expected size: 12 (nonce) + 32 (ciphertext) + 16 (tag) = 60 bytes + assert_eq!(encrypted.len(), NONCE_SIZE + 32 + TAG_SIZE); + + // Encrypt again - should produce different result due to random nonce + let encrypted2 = encrypt_login_key(&login_key, &shared_secret).expect("Should encrypt"); + assert_ne!( + encrypted, encrypted2, + "Different nonces should produce different ciphertext" + ); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let login_key = [0x42; 32]; + let shared_secret = [0xEF; 32]; + + let encrypted = encrypt_login_key(&login_key, &shared_secret).expect("Should encrypt"); + + // Manually decrypt to verify + let nonce = &encrypted[..NONCE_SIZE]; + let ciphertext = &encrypted[NONCE_SIZE..]; + + let cipher = Aes256Gcm::new_from_slice(&shared_secret).expect("Should create cipher"); + let decrypted = cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .expect("Should decrypt"); + + assert_eq!(decrypted.as_slice(), &login_key); + } + + #[test] + fn test_coin_type_selection() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + // Mainnet and testnet should produce different keys due to different coin types + let key_mainnet = derive_login_key(&seed, Network::Dash, &identity_id, &contract_id, 0) + .expect("Should derive"); + let key_testnet = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + + assert_ne!( + key_mainnet, key_testnet, + "Different networks should produce different keys" + ); + } +} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d8413fad..8e2a85efa 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -51,6 +51,7 @@ pub mod identity; pub mod mnlist; pub mod platform_info; pub mod register_contract; +pub mod sign_and_broadcast_state_transition; pub mod system_task; pub mod tokens; pub mod update_data_contract; @@ -86,6 +87,11 @@ pub enum BackendTask { CoreTask(CoreTask), DashPayTask(Box), BroadcastStateTransition(StateTransition), + SignAndBroadcastStateTransition { + identity: QualifiedIdentity, + signing_key: dash_sdk::platform::IdentityPublicKey, + state_transition: Box, + }, TokenTask(Box), SystemTask(SystemTask), MnListTask(mnlist::MnListTask), @@ -166,6 +172,10 @@ pub enum BackendTaskSuccessResult { DashPayContactAlreadyEstablished(Identifier), // Contact ID that already exists DashPayContactInfoUpdated(Identifier), // Contact ID whose info was updated DashPayPaymentSent(String, String, f64), // (recipient, address, amount) + KeyExchangeComplete { + contract_id: Identifier, + app_label: Option, + }, GeneratedZKProof(ProofDataOutput), VerifiedZKProof(bool, ProofDataOutput), GeneratedReceiveAddress { @@ -337,6 +347,19 @@ impl AppContext { self.broadcast_state_transition(state_transition, &sdk) .await } + BackendTask::SignAndBroadcastStateTransition { + identity, + signing_key, + state_transition, + } => { + self.sign_and_broadcast_state_transition( + &sdk, + &identity, + &signing_key, + *state_transition, + ) + .await + } BackendTask::TokenTask(token_task) => { self.run_token_task(*token_task, &sdk, sender).await } diff --git a/src/backend_task/sign_and_broadcast_state_transition.rs b/src/backend_task/sign_and_broadcast_state_transition.rs new file mode 100644 index 000000000..f557f6645 --- /dev/null +++ b/src/backend_task/sign_and_broadcast_state_transition.rs @@ -0,0 +1,36 @@ +//! Sign and broadcast state transitions requested via dash-st: URIs + +use dash_sdk::Sdk; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::platform::IdentityPublicKey; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use super::BackendTaskSuccessResult; + +impl AppContext { + /// Sign and broadcast a state transition on behalf of an external application + /// + /// This is used for dash-st: URI requests where an external app provides + /// an unsigned state transition that needs signing and broadcasting. + pub async fn sign_and_broadcast_state_transition( + &self, + sdk: &Sdk, + identity: &QualifiedIdentity, + signing_key: &IdentityPublicKey, + mut state_transition: StateTransition, + ) -> Result { + // Sign the state transition using the identity's key + state_transition + .sign_external(signing_key, identity, None:: _>) + .map_err(|e| format!("Failed to sign state transition: {}", e))?; + + // Broadcast the signed state transition + match state_transition.broadcast(sdk, None).await { + Ok(_) => Ok(BackendTaskSuccessResult::BroadcastedStateTransition), + Err(e) => Err(format!("Error broadcasting state transition: {}", e)), + } + } +} diff --git a/src/model/key_exchange_request.rs b/src/model/key_exchange_request.rs new file mode 100644 index 000000000..63c67c57d --- /dev/null +++ b/src/model/key_exchange_request.rs @@ -0,0 +1,469 @@ +//! Key Exchange Request data structures for YAPPR protocol +//! +//! This module handles parsing and validation of `dash-key:` URIs used for +//! web app key exchange (YAPPR - Yet Another Protocol for Platform Requests). + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::base58; +use dash_sdk::platform::Identifier; + +/// Maximum label length in bytes (per spec) +const MAX_LABEL_LENGTH: usize = 64; + +/// A parsed key exchange request from a `dash-key:` URI. +/// +/// Web apps generate these URIs (displayed as QR codes or copyable text) to request +/// a deterministic login key from the wallet. +#[derive(Debug, Clone, PartialEq)] +pub struct KeyExchangeRequest { + /// Protocol version (must be 1 for current implementation) + pub version: u8, + /// App's ephemeral public key for ECDH (33 bytes compressed secp256k1) + pub app_ephemeral_pub_key: [u8; 33], + /// Target contract ID that the login key is for + pub contract_id: Identifier, + /// Key derivation index + pub key_index: u32, + /// Optional display label for the app (0-64 chars) + pub label: Option, +} + +impl KeyExchangeRequest { + /// Parse a `dash-key:` URI into a KeyExchangeRequest and network. + /// + /// URI Format: `dash-key:?n=&v=` + /// + /// The base58 data contains (per YAPPR spec Section 8.1): + /// - Version (1 byte) - offset 0 + /// - App ephemeral public key (33 bytes) - offset 1-33 + /// - Contract ID (32 bytes) - offset 34-65 + /// - Key index (4 bytes, little-endian) - offset 66-69 + /// - Label length (1 byte) - offset 70 + /// - Label (0-64 bytes) - offset 71+ + /// + /// Query parameters: + /// - `n`: Network (mainnet, testnet, devnet) - required + /// - `v`: Version (must be 1) - required, must match payload version + /// + /// # Returns + /// A tuple of (KeyExchangeRequest, Network) on success, or an error string. + pub fn from_uri(uri: &str) -> Result<(Self, Network), String> { + // Check prefix + if !uri.starts_with("dash-key:") { + return Err("Invalid URI format - must start with 'dash-key:'".to_string()); + } + + // Split off the prefix + let rest = &uri[9..]; // Skip "dash-key:" + + // Split into data and query parts + let (data_part, query_part) = if let Some(pos) = rest.find('?') { + (&rest[..pos], Some(&rest[pos + 1..])) + } else { + (rest, None) + }; + + // Parse query parameters + let mut query_version: Option = None; + let mut network = Network::Dash; // Default to mainnet + + if let Some(query) = query_part { + for param in query.split('&') { + let parts: Vec<&str> = param.split('=').collect(); + if parts.len() != 2 { + continue; + } + + match parts[0] { + "v" => { + query_version = Some( + parts[1] + .parse::() + .map_err(|_| "Invalid version parameter")?, + ); + } + "n" => { + network = match parts[1].to_lowercase().as_str() { + "mainnet" | "dash" | "m" => Network::Dash, + "testnet" | "t" => Network::Testnet, + "devnet" | "d" => Network::Devnet, + "regtest" | "r" => Network::Regtest, + _ => return Err(format!("Unknown network: {}", parts[1])), + }; + } + _ => {} // Ignore unknown parameters + } + } + } + + // Decode base58 data + let data = + base58::decode(data_part).map_err(|e| format!("Invalid base58 encoding: {}", e))?; + + // Minimum data length: 1 (version) + 33 (pubkey) + 32 (contract_id) + 4 (key_index) + 1 (label_len) = 71 + if data.len() < 71 { + return Err(format!("Data too short: {} bytes (minimum 71)", data.len())); + } + + // Parse version from payload (1 byte at offset 0) + let payload_version = data[0]; + + // Validate version + if payload_version != 1 { + return Err(format!( + "Unsupported protocol version: {} (only version 1 is supported)", + payload_version + )); + } + + // If query version is specified, it must match payload version + if let Some(qv) = query_version + && qv != payload_version + { + return Err(format!( + "Version mismatch: query parameter v={} but payload version is {}", + qv, payload_version + )); + } + + // Parse app ephemeral public key (33 bytes at offset 1-33) + let app_ephemeral_pub_key: [u8; 33] = data[1..34] + .try_into() + .map_err(|_| "Failed to parse ephemeral public key")?; + + // Validate the public key format (must start with 0x02 or 0x03 for compressed) + if app_ephemeral_pub_key[0] != 0x02 && app_ephemeral_pub_key[0] != 0x03 { + return Err("Invalid ephemeral public key format (must be compressed)".to_string()); + } + + // Parse contract ID (32 bytes at offset 34-65) + let contract_id_bytes: [u8; 32] = data[34..66] + .try_into() + .map_err(|_| "Failed to parse contract ID")?; + let contract_id = Identifier::from_bytes(&contract_id_bytes) + .map_err(|e| format!("Invalid contract ID: {}", e))?; + + // Parse key index (4 bytes, little-endian at offset 66-69) + let key_index = u32::from_le_bytes( + data[66..70] + .try_into() + .map_err(|_| "Failed to parse key index")?, + ); + + // Parse label length (1 byte at offset 70) + let label_len = data[70] as usize; + + // Validate label length + if label_len > MAX_LABEL_LENGTH { + return Err(format!( + "Label too long: {} bytes (maximum {})", + label_len, MAX_LABEL_LENGTH + )); + } + + // Validate total length matches expected + let expected_len = 71 + label_len; + if data.len() != expected_len { + return Err(format!( + "Invalid data length: {} bytes (expected {})", + data.len(), + expected_len + )); + } + + // Parse label if present (offset 71+) + let label = if label_len > 0 { + let label_bytes = &data[71..71 + label_len]; + Some(String::from_utf8(label_bytes.to_vec()).map_err(|_| "Invalid UTF-8 in label")?) + } else { + None + }; + + Ok(( + Self { + version: payload_version, + app_ephemeral_pub_key, + contract_id, + key_index, + label, + }, + network, + )) + } + + /// Get the display name for this request (label or "Unknown App") + pub fn display_name(&self) -> &str { + self.label.as_deref().unwrap_or("Unknown App") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_uri( + contract_id: &[u8; 32], + key_index: u32, + pubkey: &[u8; 33], + label: Option<&str>, + network: Option<&str>, + version: Option, + ) -> String { + // Build data per YAPPR spec Section 8.1: + // - Version (1 byte) - offset 0 + // - App ephemeral public key (33 bytes) - offset 1-33 + // - Contract ID (32 bytes) - offset 34-65 + // - Key index (4 bytes, little-endian) - offset 66-69 + // - Label length (1 byte) - offset 70 + // - Label (0-64 bytes) - offset 71+ + let mut data = Vec::new(); + data.push(version.unwrap_or(1)); // Version byte + data.extend_from_slice(pubkey); // 33 bytes + data.extend_from_slice(contract_id); // 32 bytes + data.extend_from_slice(&key_index.to_le_bytes()); // 4 bytes, little-endian + + if let Some(l) = label { + data.push(l.len() as u8); + data.extend_from_slice(l.as_bytes()); + } else { + data.push(0); + } + + let encoded = base58::encode_slice(&data); + let mut uri = format!("dash-key:{}", encoded); + + let mut params = Vec::new(); + if let Some(n) = network { + params.push(format!("n={}", n)); + } + if let Some(v) = version { + params.push(format!("v={}", v)); + } + + if !params.is_empty() { + uri.push('?'); + uri.push_str(¶ms.join("&")); + } + + uri + } + + #[test] + fn test_parse_valid_uri_no_label() { + let contract_id = [0x11u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; // Compressed pubkey prefix + p + }; + + let uri = create_test_uri(&contract_id, 42, &pubkey, None, Some("testnet"), Some(1)); + + let (request, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + + assert_eq!(request.version, 1); + assert_eq!(request.key_index, 42); + assert_eq!(request.app_ephemeral_pub_key, pubkey); + assert_eq!(request.label, None); + assert_eq!(network, Network::Testnet); + } + + #[test] + fn test_parse_valid_uri_with_label() { + let contract_id = [0x22u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x03; + p + }; + + let uri = create_test_uri( + &contract_id, + 100, + &pubkey, + Some("My Cool App"), + Some("mainnet"), + Some(1), + ); + + let (request, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + + assert_eq!(request.version, 1); + assert_eq!(request.key_index, 100); + assert_eq!(request.label, Some("My Cool App".to_string())); + assert_eq!(request.display_name(), "My Cool App"); + assert_eq!(network, Network::Dash); + } + + #[test] + fn test_parse_default_network_and_version() { + let contract_id = [0x33u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + // No query parameters - should default to mainnet and version 1 + let uri = create_test_uri(&contract_id, 0, &pubkey, None, None, None); + + let (request, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + + assert_eq!(request.version, 1); + assert_eq!(network, Network::Dash); + } + + #[test] + fn test_parse_short_network_codes() { + let contract_id = [0x33u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + // Test "t" for testnet + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("t"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Testnet); + + // Test "m" for mainnet + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("m"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Dash); + + // Test "d" for devnet + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("d"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Devnet); + + // Test "r" for regtest + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("r"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Regtest); + } + + #[test] + fn test_reject_unsupported_version() { + let contract_id = [0x44u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + let uri = create_test_uri(&contract_id, 0, &pubkey, None, None, Some(2)); + + let result = KeyExchangeRequest::from_uri(&uri); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported protocol version")); + } + + #[test] + fn test_reject_invalid_prefix() { + let result = KeyExchangeRequest::from_uri("dash:?di=abc123"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("must start with 'dash-key:'")); + } + + #[test] + fn test_reject_invalid_pubkey_format() { + let contract_id = [0x55u8; 32]; + let pubkey = [0x04u8; 33]; // Invalid - 0x04 is uncompressed prefix + + let uri = create_test_uri(&contract_id, 0, &pubkey, None, None, None); + + let result = KeyExchangeRequest::from_uri(&uri); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("must be compressed")); + } + + #[test] + fn test_reject_label_too_long() { + let contract_id = [0x66u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + // Create URI with 65-byte label (exceeds 64 byte limit) + let long_label = "x".repeat(65); + let uri = create_test_uri(&contract_id, 0, &pubkey, Some(&long_label), None, None); + + let result = KeyExchangeRequest::from_uri(&uri); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Label too long")); + } + + #[test] + fn test_display_name_with_label() { + let request = KeyExchangeRequest { + version: 1, + app_ephemeral_pub_key: [0x02; 33], + contract_id: Identifier::from_bytes(&[0u8; 32]).unwrap(), + key_index: 0, + label: Some("Test App".to_string()), + }; + + assert_eq!(request.display_name(), "Test App"); + } + + #[test] + fn test_display_name_without_label() { + let request = KeyExchangeRequest { + version: 1, + app_ephemeral_pub_key: [0x02; 33], + contract_id: Identifier::from_bytes(&[0u8; 32]).unwrap(), + key_index: 0, + label: None, + }; + + assert_eq!(request.display_name(), "Unknown App"); + } + #[test] + fn test_parse_real_yappr_uris() { + // Real URIs from Yappr app - both should parse to the same contract ID + let uri1 = "dash-key:KW76jFtvfVEtLAhZAhfAXMjS9kQHFTiuHg4v2yTyTEXurNUvDz14brC1YFKW72u5R1UgHQkfGS7GWdEYqRCVdk4XCEuieHq8xUDQBwmqwFxdyKGdPRT?n=t&v=1"; + let uri2 = "dash-key:KTeX5FvM4rd8jp3sSfZESA9TKokwH1nsRuZZNFno8j7aFitsuordJRdVu5NmieVd7YL77XMYrjm4GzGQS36fTfCK4rR5XiVVxnTTMM7EWqSR8VbybCD?n=t&v=1"; + + let (request1, network1) = KeyExchangeRequest::from_uri(uri1).expect("Should parse URI 1"); + let (request2, network2) = KeyExchangeRequest::from_uri(uri2).expect("Should parse URI 2"); + + // Both should be testnet + assert_eq!(network1, Network::Testnet); + assert_eq!(network2, Network::Testnet); + + // Both should have version 1 + assert_eq!(request1.version, 1); + assert_eq!(request2.version, 1); + + // Contract IDs should be the same (same app) + assert_eq!( + request1.contract_id, request2.contract_id, + "Contract IDs should match for the same app" + ); + + // Key indices should be 0 + assert_eq!(request1.key_index, 0); + assert_eq!(request2.key_index, 0); + + // Labels should be "Login to Yappr" + assert_eq!(request1.label, Some("Login to Yappr".to_string())); + assert_eq!(request2.label, Some("Login to Yappr".to_string())); + + // Ephemeral public keys should be different (fresh for each request) + assert_ne!( + request1.app_ephemeral_pub_key, request2.app_ephemeral_pub_key, + "Ephemeral keys should differ between requests" + ); + + // Both ephemeral keys should start with 0x02 or 0x03 (compressed pubkey) + assert!( + request1.app_ephemeral_pub_key[0] == 0x02 || request1.app_ephemeral_pub_key[0] == 0x03 + ); + assert!( + request2.app_ephemeral_pub_key[0] == 0x02 || request2.app_ephemeral_pub_key[0] == 0x03 + ); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index de9df9441..b32c459d8 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,9 +2,11 @@ pub mod amount; pub mod contested_name; pub mod fee_estimation; pub mod grovestark_prover; +pub mod key_exchange_request; pub mod password_info; pub mod proof_log_item; pub mod qualified_contract; pub mod qualified_identity; pub mod settings; +pub mod state_transition_request; pub mod wallet; diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index a19eed6d7..055b154a6 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -54,8 +54,8 @@ impl Encode for WalletDerivationPath { } } -impl Decode for WalletDerivationPath { - fn decode(decoder: &mut D) -> Result { +impl Decode for WalletDerivationPath { + fn decode>(decoder: &mut D) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; @@ -92,8 +92,10 @@ impl Decode for WalletDerivationPath { } } -impl<'de, Context> BorrowDecode<'de, Context> for WalletDerivationPath { - fn borrow_decode>(decoder: &mut D) -> Result { +impl<'de, C> BorrowDecode<'de, C> for WalletDerivationPath { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 4152843ab..510d10f0d 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -270,8 +270,8 @@ impl Encode for QualifiedIdentity { } // Implement Decode manually for QualifiedIdentity, excluding decrypted_wallets -impl Decode for QualifiedIdentity { - fn decode( +impl Decode for QualifiedIdentity { + fn decode>( decoder: &mut D, ) -> Result { Ok(Self { diff --git a/src/model/state_transition_request.rs b/src/model/state_transition_request.rs new file mode 100644 index 000000000..cca8bf278 --- /dev/null +++ b/src/model/state_transition_request.rs @@ -0,0 +1,546 @@ +//! State Transition Request data structures for DIP-signing-request protocol +//! +//! This module handles parsing and validation of `dash-st:` URIs used for +//! external applications to request state transition signing and broadcasting. + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::base58; +use dash_sdk::dpp::identity::KeyID; +use dash_sdk::dpp::serialization::PlatformDeserializable; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; +use dash_sdk::dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dash_sdk::dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; +use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dash_sdk::dpp::state_transition::identity_credit_transfer_transition::IdentityCreditTransferTransition; +use dash_sdk::dpp::state_transition::identity_credit_withdrawal_transition::IdentityCreditWithdrawalTransition; +use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; +use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dash_sdk::dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dash_sdk::platform::Identifier; + +/// Maximum label length in bytes (per spec) +const MAX_LABEL_LENGTH: usize = 64; + +/// A parsed state transition request from a `dash-st:` URI. +/// +/// External applications generate these URIs to request the wallet to sign +/// and broadcast state transitions on their behalf. +#[derive(Debug, Clone)] +pub struct StateTransitionRequest { + /// Protocol version (must be 1 for current implementation) + pub version: u8, + /// The state transition to be signed and broadcast + pub state_transition: StateTransition, + /// Optional identity ID hint (identity that should sign) + pub identity_hint: Option, + /// Optional key ID hint (specific key that should be used) + pub key_id_hint: Option, + /// Optional display label for the request (0-64 chars) + pub label: Option, +} + +impl StateTransitionRequest { + /// Parse a `dash-st:` URI into a StateTransitionRequest and network. + /// + /// URI Format: `dash-st:?n=&v=[&id=][&k=][&l=