From 779b0155131e21fcdbcfc1c79db8cd44a0acc5b5 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 18:49:13 -0500 Subject: [PATCH 1/4] feat(platform-wallet): register limited keys as version 1 keys in creation IdentityPubkeyFFI rows with has_total_budget / has_expires_at already became IdentityPublicKey::V1 through key_with_row_limits (#4811); this documents the consensus rules the FFI deliberately leaves to Platform (AUTHENTICATION below MASTER, non-zero budget, future expiry) and pins the path with tests: a limited row decodes to a V1 key and reaches IdentityUpdateTransition::try_from_identity_with_signer as IdentityPublicKeyInCreation::V1 with the limits inside the signed bytes, while an unlimited row stays V0. Co-Authored-By: Claude Fable 5.1 --- .../src/identity_registration_with_signer.rs | 109 +++++++++++++++- .../src/wallet/identity/network/update.rs | 122 ++++++++++++++++++ 2 files changed, 228 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs index 7dbda21ff29..ec6385447f8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs @@ -116,9 +116,24 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// the credits its transitions may take from the identity over its /// lifetime, when `has_total_budget`; `expires_at`, the block time in /// milliseconds from which it can no longer sign, when -/// `has_expires_at`. Only AUTHENTICATION keys below MASTER may carry -/// them; a row with neither flag registers a version 0 key, the same -/// bytes as ever. +/// `has_expires_at`. A row with either flag set is registered as an +/// `IdentityPublicKeyInCreation::V1` (the limits are part of the +/// signable bytes, so the identity signs what it grants); a row with +/// neither flag registers a version 0 key, the same bytes as ever. The +/// `0` values behind an unset flag are ignored, so a caller that +/// encodes "no limit" as `0` must leave the flag false: a budget of +/// literally zero is refused by consensus +/// (`InvalidIdentityPublicKeyBudgetError`, 10537). +/// +/// Consensus rules the FFI deliberately does not duplicate (see +/// `docs/protocol/authentication-key-limits.md`): limits are allowed +/// only on AUTHENTICATION keys with a security level below MASTER +/// (`IdentityPublicKeyLimitsNotAllowedError`, 10536), a budget must be +/// non-zero (10537), and an expiry must lie after the registering +/// block's time (`IdentityPublicKeyAlreadyExpiredError`, 40219, a paid +/// failure; seconds instead of milliseconds is the usual way there). +/// Platform enforces all three at validation; the FFI passes the row +/// through so the error the caller sees is the chain's own. /// /// All pointers are borrowed for the call duration only — the /// FFI does not retain or free them. @@ -949,6 +964,94 @@ mod tests { assert_eq!(map.len(), 2); } + /// A row with a budget, an expiry, or both decodes to a version 1 key + /// carrying exactly those limits; a row with neither flag stays version + /// 0 so identities that do not use limits keep their historical bytes. + /// The `0` behind an unset flag is not read. + #[test] + fn key_with_row_limits_builds_v1_only_when_a_limit_is_set() { + use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + + let pk = [0x02u8; 33]; + let base = || { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 7, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(pk.to_vec()), + disabled_at: None, + }) + }; + + let mut unlimited = ffi_row(7, &pk); + unlimited.total_budget = 5; // ignored: has_total_budget is false + unlimited.expires_at = 5; // ignored: has_expires_at is false + let key = key_with_row_limits(base(), &unlimited); + assert!(matches!(key, IdentityPublicKey::V0(_))); + assert_eq!(key.total_budget(), None); + assert_eq!(key.expires_at(), None); + + let mut budget_only = ffi_row(7, &pk); + budget_only.has_total_budget = true; + budget_only.total_budget = 10_000_000_000; + let key = key_with_row_limits(base(), &budget_only); + assert!(matches!(key, IdentityPublicKey::V1(_))); + assert_eq!(key.total_budget(), Some(10_000_000_000)); + assert_eq!(key.expires_at(), None); + + let mut expiry_only = ffi_row(7, &pk); + expiry_only.has_expires_at = true; + expiry_only.expires_at = 1_800_000_000_000; + let key = key_with_row_limits(base(), &expiry_only); + assert!(matches!(key, IdentityPublicKey::V1(_))); + assert_eq!(key.total_budget(), None); + assert_eq!(key.expires_at(), Some(1_800_000_000_000)); + + let mut both = ffi_row(7, &pk); + both.has_total_budget = true; + both.total_budget = 10_000_000_000; + both.has_expires_at = true; + both.expires_at = 1_800_000_000_000; + let key = key_with_row_limits(base(), &both); + assert!(matches!(key, IdentityPublicKey::V1(_))); + assert_eq!(key.total_budget(), Some(10_000_000_000)); + assert_eq!(key.expires_at(), Some(1_800_000_000_000)); + } + + /// The identity create / update transitions carry the key as an + /// `IdentityPublicKeyInCreation`; a limited row must become the V1 + /// variant there too, since the limits are part of the signable bytes. + #[test] + fn limited_row_becomes_a_v1_key_in_creation() { + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; + use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + + let pk = [0x02u8; 33]; + let mut session = ffi_row(9, &pk); + session.security_level = 2; // SecurityLevel::HIGH + session.has_total_budget = true; + session.total_budget = 10_000_000_000; + session.has_expires_at = true; + session.expires_at = 1_800_000_000_000; + let rows = [ffi_row(0, &pk), session]; + + // SAFETY: `rows` (and the pubkey array it borrows) outlive the call. + let map = unsafe { decode_identity_pubkeys(rows.as_ptr(), rows.len()) } + .expect("limited authentication key must decode"); + + let master = IdentityPublicKeyInCreation::from(&map[&0]); + assert!(matches!(master, IdentityPublicKeyInCreation::V0(_))); + assert!(!master.has_limits()); + + let session = IdentityPublicKeyInCreation::from(&map[&9]); + assert!(matches!(session, IdentityPublicKeyInCreation::V1(_))); + assert_eq!(session.total_budget(), Some(10_000_000_000)); + assert_eq!(session.expires_at(), Some(1_800_000_000_000)); + } + #[test] fn decode_contract_bounds_accepts_none_for_authentication_encryption_and_decryption() { let pk = [0x02u8; 33]; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 475a12eb2e3..ba38432009d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -271,6 +271,17 @@ impl IdentityWallet { /// **not** look up the identity in the internal `IdentityManager`. The /// caller supplies the `Identity`, master key ID, and a `Signer` directly. /// + /// Keys in `add_public_keys` may be `IdentityPublicKey::V1` carrying a + /// `total_budget` and/or `expires_at` (protocol version 14). The + /// transition builder turns each key into the matching + /// `IdentityPublicKeyInCreation` version, so a V1 key is registered + /// with its limits inside the signed bytes and a V0 key is registered + /// exactly as before. The consensus rules on limits (AUTHENTICATION + /// purpose and a level below MASTER, non-zero budget, expiry in the + /// future) are not re-checked here: Platform validates them and the + /// caller receives the chain's error. See + /// `docs/protocol/authentication-key-limits.md`. + /// /// Returns the [`StateTransitionProofResult`] from the broadcast so callers /// can inspect proof-verified outcomes (e.g. updated keys, balance). pub async fn update_identity_with_signer>( @@ -320,3 +331,114 @@ impl IdentityWallet { Ok(result) } } + +#[cfg(test)] +mod tests { + //! `update_identity_with_signer` hands its `add_public_keys` straight to + //! `IdentityUpdateTransition::try_from_identity_with_signer`; the network + //! round trip around that call is not unit-testable, so these tests pin + //! the builder step: a V1 key with limits must reach the transition as a + //! V1 key in creation, with the limits inside the signed bytes, while a + //! V0 key stays V0. + + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; + use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; + use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; + use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use dpp::state_transition::StateTransition; + use dpp::version::PlatformVersion; + use simple_signer::signer::SimpleSigner; + + /// A deterministic secp256k1 key pair for the master key that signs the + /// update and for the key being added (the added key signs its own + /// witness because ECDSA_SECP256K1 is a unique key type). + fn keypair(seed: u8) -> ([u8; 32], Vec) { + let secret = [seed; 32]; + let secp = dashcore::secp256k1::Secp256k1::new(); + let sk = dashcore::secp256k1::SecretKey::from_slice(&secret).expect("valid scalar"); + let pk = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &sk); + (secret, pk.serialize().to_vec()) + } + + fn auth_key(id: u32, level: SecurityLevel, data: Vec) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: data.into(), + disabled_at: None, + }) + } + + async fn build_update(add_public_keys: Vec) -> IdentityUpdateTransition { + let (master_secret, master_pub) = keypair(0x11); + let master = auth_key(0, SecurityLevel::MASTER, master_pub); + + let mut identity = Identity::default_versioned(PlatformVersion::latest()).unwrap(); + identity.add_public_key(master.clone()); + + let mut signer = SimpleSigner::default(); + signer.add_identity_public_key(master, master_secret); + for key in &add_public_keys { + // Every added key in this module is derived from `keypair(0x22)`. + signer.add_identity_public_key(key.clone(), keypair(0x22).0); + } + + let transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &0, + add_public_keys, + vec![], + 1, + 0, + &signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("identity update builds"); + + match transition { + StateTransition::IdentityUpdate(update) => update, + other => panic!("expected an IdentityUpdate, got {}", other.name()), + } + } + + #[tokio::test] + async fn limited_key_is_registered_as_a_v1_key_in_creation() { + let (_, session_pub) = keypair(0x22); + let session = auth_key(5, SecurityLevel::HIGH, session_pub) + .with_limits(Some(10_000_000_000), Some(1_800_000_000_000)); + assert!(matches!(session, IdentityPublicKey::V1(_))); + + let update = build_update(vec![session]).await; + + let [added] = update.public_keys_to_add() else { + panic!("expected exactly one added key"); + }; + assert!(matches!(added, IdentityPublicKeyInCreation::V1(_))); + assert_eq!(added.total_budget(), Some(10_000_000_000)); + assert_eq!(added.expires_at(), Some(1_800_000_000_000)); + } + + #[tokio::test] + async fn unlimited_key_stays_a_v0_key_in_creation() { + let (_, plain_pub) = keypair(0x22); + let plain = auth_key(5, SecurityLevel::HIGH, plain_pub); + + let update = build_update(vec![plain]).await; + + let [added] = update.public_keys_to_add() else { + panic!("expected exactly one added key"); + }; + assert!(matches!(added, IdentityPublicKeyInCreation::V0(_))); + assert!(!added.has_limits()); + } +} From 0567d92fa96942af4f8f35f2a5b8b9d18d4a2b2a Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 18:49:36 -0500 Subject: [PATCH 2/4] feat(platform-wallet): derive DashPay Connect keys at the DIP-13 sub-feature paths Adds connect_key_derivation_path / derive_connect_keypair_from_master, building m/9'/coin'/5'/'/0'/'/'[/'] with DIP-14 256-bit hardened children for the identity id and the leaf, so nothing wallet-local (identity ordinal, key counter) is an input and two devices restored from one seed derive the same key. Sub-feature 6' is the session authentication key (leaf = request id); 7' is the app encryption pair (leaf = bound contract id, purpose' = 1 ENCRYPTION or 2 DECRYPTION). Registered by the DIP-13 amendment dashpay/dips#191. The FFI entry dash_sdk_derive_connect_key_with_resolver follows dash_sdk_derive_identity_key_at_slot_with_resolver: the mnemonic is pulled through the client-owned resolver into a zeroized buffer for the call only, and the returned ConnectDerivedKeyFFI is plain data the paired _free zeroizes. Purpose values other than 0, 1 and 2 are refused. Fixed vectors (all-zero-entropy mnemonic, identity 0x35*32, leaf 0x6B*32) are pinned for both networks. Co-Authored-By: Claude Fable 5.1 --- .../src/derive_connect_key.rs | 487 ++++++++++++++++++ packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../identity/network/identity_handle.rs | 362 +++++++++++++ .../src/wallet/identity/network/mod.rs | 4 +- 4 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet-ffi/src/derive_connect_key.rs diff --git a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs new file mode 100644 index 00000000000..655eaf73984 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs @@ -0,0 +1,487 @@ +//! DashPay Connect key derivation at the DIP-13 sub-feature paths +//! (dashpay/dips#191), driven by the resolver-backed seed. +//! +//! Both DashPay Connect v2 keys live under new DIP-13 sub-features with +//! DIP-14 256-bit hardened leaves, so no wallet-local counter is an input +//! and two devices restored from one seed derive the same key: +//! +//! ```text +//! session auth: m / 9' / coin' / 5' / 6' / 0' / identityId' / requestId' +//! app encryption: m / 9' / coin' / 5' / 7' / 0' / identityId' / contractId' / purpose' +//! ``` +//! +//! The wallet derives every key; the app never sees the seed. This module +//! is the FFI mirror of +//! [`platform_wallet::wallet::identity::network::derive_connect_keypair_from_master`] +//! and follows the resolver contract of +//! [`crate::dash_sdk_derive_identity_key_at_slot_with_resolver`]: the +//! mnemonic is pulled through the Swift/Kotlin-owned `MnemonicResolver` +//! for the duration of the call only, in a zeroized buffer. + +use std::ffi::c_void; +use std::os::raw::c_char; + +use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::wallet::identity::network::derive_connect_keypair_from_master; +use zeroize::Zeroizing; + +use crate::error::*; +use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; +use crate::types::{read_identifier, FFINetwork, Network}; +use crate::{check_ptr, unwrap_result_or_return}; +use rs_sdk_ffi::{ + mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, +}; + +/// `sub_feature` for the DashPay Connect session authentication key. The +/// leaf is the connect request id (`hash256` of the app's ephemeral public +/// key); `purpose` is `0`. +pub const CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION: u32 = + platform_wallet::wallet::identity::network::CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION; + +/// `sub_feature` for the DashPay Connect app encryption key pair. The leaf +/// is the id of the data contract the key is bound to; `purpose` is the +/// DPP purpose discriminant, `1` ENCRYPTION or `2` DECRYPTION. +pub const CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION: u32 = + platform_wallet::wallet::identity::network::CONNECT_SUB_FEATURE_APP_ENCRYPTION; + +/// `purpose` for the ENCRYPTION half of an app encryption pair (the DPP +/// `Purpose::ENCRYPTION` discriminant). +pub const CONNECT_KEY_PURPOSE_ENCRYPTION: u32 = 1; + +/// `purpose` for the DECRYPTION half of an app encryption pair (the DPP +/// `Purpose::DECRYPTION` discriminant). +pub const CONNECT_KEY_PURPOSE_DECRYPTION: u32 = 2; + +/// A derived DashPay Connect keypair. Plain old data: the caller copies +/// what it needs and calls [`dash_sdk_derive_connect_key_free`] to wipe +/// the private scalar. +#[repr(C)] +pub struct ConnectDerivedKeyFFI { + /// Raw 32-byte secp256k1 private scalar. Inline so the free path can + /// zeroize it without chasing a pointer. + pub private_key_bytes: [u8; 32], + /// Compressed secp256k1 public key, always 33 bytes. + pub public_key_bytes: [u8; 33], +} + +impl ConnectDerivedKeyFFI { + pub const fn empty() -> Self { + Self { + private_key_bytes: [0u8; 32], + public_key_bytes: [0u8; 33], + } + } +} + +impl Default for ConnectDerivedKeyFFI { + fn default() -> Self { + Self::empty() + } +} + +/// Derive the DashPay Connect key at +/// `m/9'/coin'/5'/'/0'/'/'[/']` +/// from the mnemonic the resolver returns for `wallet_id_bytes`. +/// +/// - `network` picks the coin type (`5'` mainnet, `1'` otherwise), as the +/// existing identity derivations do. +/// - `sub_feature` is `6` (session authentication) or `7` (app +/// encryption); any 31-bit value derives, the protocol decides which +/// are meaningful. +/// - `identity_id_bytes` and `leaf_bytes` are 32-byte DIP-14 hardened +/// children: the identity's own id, and the request id or bound +/// contract id. +/// - `purpose` is `0` for no purpose level (the session authentication +/// path), or the DPP purpose discriminant of the half being derived: +/// `1` ENCRYPTION or `2` DECRYPTION, appended as one further hardened +/// child. The DIP-13 amendment defines exactly those two, so any other +/// value is refused with `ErrorInvalidParameter` rather than derived +/// into a key nothing will ever look for. +/// +/// The derived key is written to `out_key`; call +/// [`dash_sdk_derive_connect_key_free`] when done with it. +/// +/// # Safety +/// - `wallet_id_bytes`, `identity_id_bytes` and `leaf_bytes` must point to +/// 32 readable bytes each. +/// - `mnemonic_resolver_handle` must be a live handle from +/// `dash_sdk_mnemonic_resolver_create`. +/// - `out_key` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( + network: FFINetwork, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + sub_feature: u32, + identity_id_bytes: *const u8, + leaf_bytes: *const u8, + purpose: u32, + out_key: *mut ConnectDerivedKeyFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_key); + *out_key = ConnectDerivedKeyFFI::empty(); + + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(leaf_bytes); + + let purpose = match purpose { + 0 => None, + CONNECT_KEY_PURPOSE_ENCRYPTION | CONNECT_KEY_PURPOSE_DECRYPTION => Some(purpose), + other => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "purpose {other} is not a DashPay Connect key purpose (0 = none, \ + 1 = ENCRYPTION, 2 = DECRYPTION)" + ), + ); + } + }; + + let identity_id = unwrap_result_or_return!(read_identifier(identity_id_bytes)); + let leaf: [u8; 32] = std::slice::from_raw_parts(leaf_bytes, 32) + .try_into() + .expect("from_raw_parts(_, 32) always yields exactly 32 bytes"); + + let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = + Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); + let mut mnemonic_len: usize = 0; + + let resolver = &*mnemonic_resolver_handle; + let resolver_vtable = &*resolver.vtable; + let rc = (resolver_vtable.resolve)( + resolver.ctx as *const c_void, + wallet_id_bytes, + 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 PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: no mnemonic stored for the supplied wallet_id", + ); + } + x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: mnemonic exceeded the FFI buffer capacity", + ); + } + _ => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: failed (other / Keychain access error)", + ); + } + } + if mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: reported length exceeds the FFI buffer capacity", + ); + } + + let mnemonic_str = unwrap_result_or_return!(std::str::from_utf8(&mnemonic_buf[..mnemonic_len])); + let mnemonic = unwrap_result_or_return!(parse_mnemonic_any_language(mnemonic_str)); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + + let kw_network: Network = network.into(); + let master = unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); + + let derived = unwrap_result_or_return!(derive_connect_keypair_from_master( + &master, + kw_network, + sub_feature, + &identity_id, + leaf, + purpose, + )); + + (*out_key).private_key_bytes = *derived.private_key; + (*out_key).public_key_bytes = derived.public_key; + + PlatformWalletFFIResult::ok() +} + +/// Wipe a key populated by [`dash_sdk_derive_connect_key_with_resolver`]. +/// The struct owns no heap memory; this zeroizes the private scalar in +/// place (a volatile write the optimizer cannot elide) and clears the +/// public key. Safe on a null pointer and on an already-wiped key. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_derive_connect_key_free(out_key: *mut ConnectDerivedKeyFFI) { + if out_key.is_null() { + return; + } + use zeroize::Zeroize; + (*out_key).private_key_bytes.zeroize(); + (*out_key).public_key_bytes = [0u8; 33]; +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use key_wallet::mnemonic::Mnemonic; + use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use std::ffi::CStr; + + /// English BIP-39 test vector (all-zero entropy); the same fixture the + /// platform-wallet derivation tests pin vectors for. + const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const IDENTITY: [u8; 32] = [0x35; 32]; + const LEAF: [u8; 32] = [0x6B; 32]; + + unsafe extern "C" fn test_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 = TEST_MNEMONIC.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 not_found_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 derive( + resolver: *mut MnemonicResolverHandle, + network: FFINetwork, + sub_feature: u32, + leaf: [u8; 32], + purpose: u32, + ) -> (PlatformWalletFFIResult, ConnectDerivedKeyFFI) { + let wallet_id = [0x07u8; 32]; + let mut out = ConnectDerivedKeyFFI::empty(); + let result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + network, + wallet_id.as_ptr(), + resolver, + sub_feature, + IDENTITY.as_ptr(), + leaf.as_ptr(), + purpose, + &mut out, + ) + }; + (result, out) + } + + /// The FFI derives exactly what the platform-wallet primitive derives + /// from the same seed, so the two cannot drift; and the same inputs + /// always give the same key. + #[test] + fn derives_the_library_vector_deterministically() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + + let (result, first) = derive( + resolver, + FFINetwork::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + LEAF, + 0, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + let (result, second) = derive( + resolver, + FFINetwork::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + LEAF, + 0, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(first.private_key_bytes, second.private_key_bytes); + assert_eq!(first.public_key_bytes, second.public_key_bytes); + + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap(); + let master = ExtendedPrivKey::new_master(Network::Testnet, &mnemonic.to_seed("")).unwrap(); + let expected = derive_connect_keypair_from_master( + &master, + Network::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + &Identifier::from(IDENTITY), + LEAF, + None, + ) + .unwrap(); + assert_eq!(first.private_key_bytes, *expected.private_key); + assert_eq!(first.public_key_bytes, expected.public_key); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Leaf, purpose and sub-feature each change the key; `purpose == 0` + /// appends nothing, so it differs from `purpose == 1` under the + /// encryption sub-feature. + #[test] + fn different_leaves_purposes_and_sub_features_give_different_keys() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + let key = |sub_feature, leaf, purpose| { + let (result, out) = derive(resolver, FFINetwork::Testnet, sub_feature, leaf, purpose); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + out.public_key_bytes + }; + + let auth = key(CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0); + let auth_other_leaf = key( + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + [0x6C; 32], + 0, + ); + let enc_unpurposed = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 0); + let enc = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 1); + let dec = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 2); + + let all = [auth, auth_other_leaf, enc_unpurposed, enc, dec]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "connect keys collided (index {i})"); + } + } + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn free_zeroizes_and_is_idempotent() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + let (result, mut out) = derive( + resolver, + FFINetwork::Mainnet, + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + LEAF, + 2, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_ne!(out.private_key_bytes, [0u8; 32]); + + unsafe { dash_sdk_derive_connect_key_free(&mut out) }; + assert_eq!(out.private_key_bytes, [0u8; 32]); + assert_eq!(out.public_key_bytes, [0u8; 33]); + unsafe { dash_sdk_derive_connect_key_free(&mut out) }; + unsafe { dash_sdk_derive_connect_key_free(std::ptr::null_mut()) }; + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn resolver_miss_is_a_wallet_operation_error() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), not_found_resolve, noop_destroy) + }; + let (mut result, out) = derive( + resolver, + FFINetwork::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + LEAF, + 0, + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!(message.contains("no mnemonic stored"), "{message}"); + assert_eq!(out.private_key_bytes, [0u8; 32]); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn null_resolver_is_a_null_pointer_error() { + let mut out = ConnectDerivedKeyFFI::empty(); + let wallet_id = [0x07u8; 32]; + let result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + std::ptr::null_mut(), + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + IDENTITY.as_ptr(), + LEAF.as_ptr(), + 0, + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// The identity id is read through `read_identifier`, whose null check + /// runs before the resolver is touched, so a dangling resolver handle + /// is never dereferenced here. + #[test] + fn null_identity_id_is_an_error_before_the_resolver_is_used() { + let mut out = ConnectDerivedKeyFFI::empty(); + let wallet_id = [0x07u8; 32]; + let result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + std::ptr::dangling_mut(), + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + std::ptr::null(), + LEAF.as_ptr(), + 0, + &mut out, + ) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.private_key_bytes, [0u8; 32]); + } + + /// Only the two DIP-13 purposes (and 0 for none) derive; anything else + /// is refused before the seed is resolved. + #[test] + fn unknown_purpose_is_an_invalid_parameter() { + let mut out = ConnectDerivedKeyFFI::empty(); + let wallet_id = [0x07u8; 32]; + let mut result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + std::ptr::dangling_mut(), + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + IDENTITY.as_ptr(), + LEAF.as_ptr(), + 3, + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!(message.contains("purpose 3"), "{message}"); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index dc32a5957b6..f9bf8f2c602 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -26,6 +26,7 @@ pub mod dashpay_sync; pub mod data_contract; pub mod derivation; pub mod derive_and_persist_callbacks; +pub mod derive_connect_key; pub mod derive_identity_key_at_slot; pub mod document; pub mod dpns; @@ -109,6 +110,7 @@ pub use dashpay_sync::*; pub use data_contract::*; pub use derivation::*; pub use derive_and_persist_callbacks::*; +pub use derive_connect_key::*; pub use derive_identity_key_at_slot::*; pub use document::*; pub use dpns::*; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 402b6930737..ededc31ddb6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -114,6 +114,116 @@ pub fn identity_auth_derivation_path_for_type( ])) } +/// DIP-13 sub-feature under `m/9'/coin'/5'/` for the DashPay Connect +/// session authentication key: `m/9'/coin'/5'/6'/0'/identityId'/requestId'`. +/// The leaf is the connect request's id, `hash256(appEphemeralPubKey)`. +pub const CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION: u32 = 6; + +/// DIP-13 sub-feature under `m/9'/coin'/5'/` for the DashPay Connect +/// app encryption key pair: +/// `m/9'/coin'/5'/7'/0'/identityId'/contractId'/purpose'`. The leaf is +/// the id of the data contract the key is bound to; `purpose'` is the DPP +/// purpose discriminant (`1'` ENCRYPTION, `2'` DECRYPTION), so an identity +/// holds the DashPay-style pair per contract. +pub const CONNECT_SUB_FEATURE_APP_ENCRYPTION: u32 = 7; + +/// Build the DIP-13 sub-feature derivation path DashPay Connect keys live +/// at: +/// +/// ```text +/// m / 9' / coin' / 5' / sub_feature' / 0' / ' / ' [ / purpose' ] +/// ``` +/// +/// - `9'`, `coin'`, `5'` are the DIP-9 feature purpose, the network's coin +/// type (`5'` mainnet, `1'` otherwise, as the existing identity paths +/// pick it) and the DIP-13 identity feature. +/// - `sub_feature'` is a 31-bit hardened child; today `6'` (session +/// authentication) or `7'` (app encryption). DIP-13 assigns `0'`–`5'`. +/// - `0'` is the DIP-13 key type slot (ECDSA secp256k1). +/// - `identity_id'` and `leaf'` are DIP-14 256-bit hardened children +/// ([`ChildNumber::Hardened256`]), so nothing wallet-local (identity +/// ordinal, key counter) is an input and two devices restored from one +/// seed derive the same key without coordination. +/// - `purpose'`, when `Some`, is one further 31-bit hardened child. The +/// encryption sub-feature uses it to split the ENCRYPTION (`1'`) and +/// DECRYPTION (`2'`) halves of the pair; the authentication sub-feature +/// passes `None`. +/// +/// Values are not validated against the sub-feature (a `purpose` under +/// `6'` derives a key like any other); the caller picks the shape the +/// protocol asks for. The DIP-13 amendment registering `6'` and `7'` is +/// dashpay/dips#191. +pub fn connect_key_derivation_path( + network: key_wallet::Network, + sub_feature: u32, + identity_id: &Identifier, + leaf: [u8; 32], + purpose: Option, +) -> Result { + use key_wallet::dip9::{ + DASH_COIN_TYPE, DASH_TESTNET_COIN_TYPE, FEATURE_PURPOSE, FEATURE_PURPOSE_IDENTITIES, + }; + + let coin_type = match network { + key_wallet::Network::Mainnet => DASH_COIN_TYPE, + _ => DASH_TESTNET_COIN_TYPE, + }; + let hardened = |index: u32, what: &str| { + ChildNumber::from_hardened_idx(index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid {what} {index}: {e}")) + }) + }; + + let mut path = vec![ + hardened(FEATURE_PURPOSE, "feature purpose")?, + hardened(coin_type, "coin type")?, + hardened(FEATURE_PURPOSE_IDENTITIES, "identities feature")?, + hardened(sub_feature, "sub-feature")?, + hardened(u32::from(KeyDerivationType::ECDSA), "key type")?, + ChildNumber::from_hardened_idx_256(identity_id.to_buffer()), + ChildNumber::from_hardened_idx_256(leaf), + ]; + if let Some(purpose) = purpose { + path.push(hardened(purpose, "purpose")?); + } + Ok(DerivationPath::from(path)) +} + +/// Derive the ECDSA secp256k1 keypair at the DashPay Connect path built by +/// [`connect_key_derivation_path`] from a master xpriv. Pure, like +/// [`derive_ecdsa_identity_auth_keypair_from_master`], so it works for +/// watch-only wallets whose seed the FFI resolves on demand. The returned +/// [`DerivedIdentityAuthKey`] wraps the scalar in [`Zeroizing`]; its +/// `derivation_path` renders the 256-bit children as `0x…'`. +pub fn derive_connect_keypair_from_master( + master: &ExtendedPrivKey, + network: key_wallet::Network, + sub_feature: u32, + identity_id: &Identifier, + leaf: [u8; 32], + purpose: Option, +) -> Result { + use dashcore::secp256k1::Secp256k1; + use key_wallet::bip32::ExtendedPubKey; + + let path = connect_key_derivation_path(network, sub_feature, identity_id, leaf, purpose)?; + let secp = Secp256k1::new(); + // See `derive_ecdsa_identity_auth_keypair_from_master` for why the + // intermediate `ExtendedPrivKey` needs no explicit wipe. + let derived = master.derive_priv(&secp, &path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive connect key at sub-feature {sub_feature}: {e}" + )) + })?; + let extended_pub = ExtendedPubKey::from_priv(&secp, &derived); + + Ok(DerivedIdentityAuthKey { + derivation_path: path, + private_key: Zeroizing::new(derived.private_key.secret_bytes()), + public_key: extended_pub.public_key.serialize(), + }) +} + /// One ECDSA identity-authentication keypair derived from a master /// xpriv at a specific `(identity_index, key_index)` slot. Wraps /// the secret scalar in [`Zeroizing`] so it is wiped on drop — @@ -642,4 +752,256 @@ mod tests { ); } } + + // ── DashPay Connect sub-feature derivation ──────────────────────── + + const CONNECT_IDENTITY: [u8; 32] = [0x35; 32]; + const CONNECT_LEAF: [u8; 32] = [0x6B; 32]; + + /// The connect path has the documented shape: DIP-9 prefix, the + /// sub-feature, the ECDSA key-type slot, then two DIP-14 256-bit + /// hardened children, and an optional trailing 31-bit `purpose'`. + #[test] + fn connect_key_derivation_path_has_the_documented_shape() { + let identity = Identifier::from(CONNECT_IDENTITY); + + let auth = connect_key_derivation_path( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("auth path builds"); + let auth_children: Vec = auth.as_ref().to_vec(); + assert_eq!( + auth_children, + vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 5 }, + ChildNumber::Hardened { index: 6 }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Hardened256 { + index: CONNECT_IDENTITY + }, + ChildNumber::Hardened256 { + index: CONNECT_LEAF + }, + ] + ); + + let enc = connect_key_derivation_path( + Network::Mainnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(1), + ) + .expect("encryption path builds"); + let enc_children: Vec = enc.as_ref().to_vec(); + assert_eq!(enc_children.len(), 8); + assert_eq!( + enc_children[1], + ChildNumber::Hardened { index: 5 }, + "mainnet coin type" + ); + assert_eq!(enc_children[3], ChildNumber::Hardened { index: 7 }); + assert_eq!( + enc_children[7], + ChildNumber::Hardened { index: 1 }, + "purpose'" + ); + + // Every level is hardened: nothing about these keys is derivable + // from a public parent. + assert!(auth_children.iter().all(ChildNumber::is_hardened)); + assert!(enc_children.iter().all(ChildNumber::is_hardened)); + + // A sub-feature outside the 31-bit hardened range is refused rather + // than silently masked. + assert!(connect_key_derivation_path( + Network::Testnet, + 1 << 31, + &identity, + CONNECT_LEAF, + None + ) + .is_err()); + } + + /// Fixed-vector determinism for the connect derivation: the same seed, + /// identity id and leaf always give the same key, on both networks, and + /// the derived public key is the compressed secp256k1 point of the + /// returned scalar. The pinned pubkeys let a future regression on the + /// Rust side (or a drift in the Swift/Kotlin ports, which re-derive the + /// same vector through the FFI) be spotted from either end. + #[test] + fn connect_keypair_is_deterministic_from_fixed_vectors() { + use dashcore::secp256k1::{PublicKey as SecpPublicKey, Secp256k1, SecretKey}; + + let identity = Identifier::from(CONNECT_IDENTITY); + let secp = Secp256k1::new(); + + for network in [Network::Mainnet, Network::Testnet] { + let master = master_for(network); + let first = derive_connect_keypair_from_master( + &master, + network, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("connect derive"); + let second = derive_connect_keypair_from_master( + &master, + network, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("connect derive"); + + assert_eq!(*first.private_key, *second.private_key); + assert_eq!(first.public_key, second.public_key); + assert_eq!(first.public_key.len(), 33); + assert!(first.public_key[0] == 0x02 || first.public_key[0] == 0x03); + + let sk = SecretKey::from_slice(first.private_key.as_ref()).expect("valid scalar"); + let pk = SecpPublicKey::from_secret_key(&secp, &sk); + assert_eq!(pk.serialize(), first.public_key, "pubkey matches scalar"); + + // The rendered path spells the 256-bit children as hex so the + // breadcrumb a client persists is unambiguous. + let rendered = first.derivation_path.to_string(); + assert!( + rendered.contains("/0x3535"), + "256-bit identity child rendered as hex: {rendered}" + ); + } + + // Pinned vectors for the test mnemonic (all-zero entropy), identity + // `[0x35; 32]`, leaf `[0x6B; 32]`. + let testnet_auth = derive_connect_keypair_from_master( + &master_for(Network::Testnet), + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("connect derive"); + let mainnet_enc = derive_connect_keypair_from_master( + &master_for(Network::Mainnet), + Network::Mainnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(1), + ) + .expect("connect derive"); + assert_eq!( + ( + hex::encode(testnet_auth.public_key), + hex::encode(mainnet_enc.public_key) + ), + ( + CONNECT_TESTNET_AUTH_PUBKEY_HEX.to_string(), + CONNECT_MAINNET_ENCRYPTION_PUBKEY_HEX.to_string() + ), + "connect pubkey vectors drifted (testnet session-auth, mainnet app-encryption)" + ); + } + + /// Changing any input (leaf, purpose, sub-feature, identity, network) + /// changes the key. In particular the ENCRYPTION and DECRYPTION halves + /// of a pair under one contract must differ, and a session key must not + /// collide with an encryption key that happens to share its leaf. + #[test] + fn connect_keypair_differs_across_leaves_purposes_and_sub_features() { + let identity = Identifier::from(CONNECT_IDENTITY); + let other_identity = Identifier::from([0x36; 32]); + let master = master_for(Network::Testnet); + let derive = |network: Network, sub: u32, id: &Identifier, leaf: [u8; 32], p| { + derive_connect_keypair_from_master(&master, network, sub, id, leaf, p) + .expect("connect derive") + .public_key + }; + + let auth = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ); + let auth_other_leaf = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + [0x6C; 32], + None, + ); + let auth_other_identity = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &other_identity, + CONNECT_LEAF, + None, + ); + let enc = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(1), + ); + let dec = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(2), + ); + let enc_no_purpose = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + None, + ); + let auth_mainnet = derive( + Network::Mainnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ); + + let all = [ + auth, + auth_other_leaf, + auth_other_identity, + enc, + dec, + enc_no_purpose, + auth_mainnet, + ]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "connect keys collided (index {i})"); + } + } + } + + // `hex::encode` of the compressed pubkeys the two pinned derivations + // above produce. Regenerate deliberately (and update the Swift and + // Kotlin vectors) if the path ever changes; a silent change here would + // orphan every key already registered on-chain. + const CONNECT_TESTNET_AUTH_PUBKEY_HEX: &str = + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d"; + const CONNECT_MAINNET_ENCRYPTION_PUBKEY_HEX: &str = + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434"; } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index ec766a7d64e..010647b9678 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -86,9 +86,11 @@ pub use dpns_marketplace::{ DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, }; pub use identity_handle::{ + connect_key_derivation_path, derive_connect_keypair_from_master, derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, - IdentityWallet, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, + IdentityWallet, CONNECT_SUB_FEATURE_APP_ENCRYPTION, CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; // Helpers declared on `identity_handle.rs` that siblings reach From 5ea94043266ee369bbf7048c9b857dc00e221d2d Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 18:59:49 -0500 Subject: [PATCH 3/4] feat(platform-wallet-ffi)!: decode any state transition kind in parse_state_transition platform_wallet_parse_state_transition accepted only an IdentityUpdate or a batch carrying exactly one TokenDirectPurchase and refused everything else. DashPay Connect's sign request hands the wallet a complete unsigned transition of any kind, so the parser now decodes every StateTransition with the untrusted decoder and returns the kind name, owner id, whether it is signed, the tagged bytes that decoded, and a typed summary for Batch (one row per batched transition with contract id, document type, action, amount and recipient), IdentityUpdate (added keys with purpose, level, bounds and limits; disabled key ids), IdentityCreditTransfer and DataContractCreate/Update; other kinds report PARSED_STATE_TRANSITION_KIND_OTHER with the common fields. Every framing is tried and a payload that decodes under more than one is refused as ambiguous instead of being described under the first that decoded. ParsedIdentityUpdatePublicKeyFFI gains has_total_budget/total_budget/has_expires_at/expires_at (appended). The serialized test fixtures are pinned under swift-sdk's test Fixtures so the Swift suite decodes the same bytes. BREAKING CHANGE: PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE, ParsedTokenDirectPurchaseFFI and ParsedStateTransitionFFI.token_direct_purchase are removed; a direct purchase is a Batch row with action DirectPurchase. Co-Authored-By: Claude Fable 5.1 --- .../src/identity_update.rs | 21 +- .../src/parse_state_transition.rs | 1348 +++++++++++++---- .../StateTransitions/credit_transfer.hex | 1 + .../StateTransitions/data_contract_create.hex | 1 + .../StateTransitions/data_contract_update.hex | 1 + .../StateTransitions/identity_update.hex | 1 + .../Fixtures/StateTransitions/mixed_batch.hex | 1 + 7 files changed, 1081 insertions(+), 293 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 666263532c1..623ef570548 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -15,7 +15,9 @@ use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; -use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, +}; use dpp::state_transition::StateTransition; use rs_sdk_ffi::{SignerHandle, VTableSigner}; @@ -52,6 +54,17 @@ pub struct ParsedIdentityUpdatePublicKeyFFI { pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *mut c_char, + /// Whether the key is registered with a `total_budget` (protocol + /// version 14, `IdentityPublicKeyInCreation::V1`). + pub has_total_budget: bool, + /// Credits the key may take from the identity over its lifetime, when + /// `has_total_budget`. + pub total_budget: u64, + /// Whether the key is registered with an `expires_at`. + pub has_expires_at: bool, + /// Block time in milliseconds from which the key can no longer sign, + /// when `has_expires_at`. + pub expires_at: u64, } /// Owned C representation of the inspectable parts of a parsed @@ -85,7 +98,7 @@ fn parse_identity_update_transition_bytes( > { // Tolerates Yappr's tagless framing next to standard tagged bytes — see // the shared helper for the ordering heuristic. - let state_transition = + let (state_transition, _tagged_bytes) = crate::parse_state_transition::deserialize_transition_with_flexible_framing( bytes, &[(IDENTITY_UPDATE_VARIANT_TAG, "IdentityUpdate")], @@ -188,6 +201,10 @@ pub(crate) fn project_parsed_identity_update( contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, + has_total_budget: public_key.total_budget().is_some(), + total_budget: public_key.total_budget().unwrap_or_default(), + has_expires_at: public_key.expires_at().is_some(), + expires_at: public_key.expires_at().unwrap_or_default(), }); } diff --git a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs index 2460dfaf975..13090125ffa 100644 --- a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs +++ b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs @@ -1,33 +1,59 @@ //! FFI parser for raw DPP state transitions handed to the wallet by a dApp -//! (DashConnect `dash-st:` links / QRs). +//! (DashConnect `dash-st:` links / QRs, DashPay Connect `sign`). //! -//! The wallet must never sign opaque bytes a web page hands it. This module -//! only reads the *intent* out of the payload so the app can show it to the -//! user; after approval the app rebuilds and signs the operation through the -//! normal wallet path (`platform_wallet_token_purchase` / -//! `platform_wallet_update_identity_with_signer`). There is deliberately no -//! "sign these transition bytes" entry point here or anywhere else in this -//! crate. +//! The wallet must never sign opaque bytes a web page hands it without +//! showing the user what they are. This module decodes any state transition +//! kind with the bounded untrusted decoder and projects a typed summary the +//! approval sheet can describe: //! -//! One umbrella parser (rather than one probe per transition kind) so the -//! app can branch on the returned `kind` discriminant instead of driving -//! control flow off "expected X, got Y" errors: DashConnect key registration -//! arrives as an `IdentityUpdateTransition`, while a dApp token purchase -//! (e.g. Yappr) arrives as a `BatchTransition` carrying a single -//! `TokenDirectPurchase` — both come through the same `dash-st:` channel and -//! are only distinguishable after deserialization. +//! - `Batch`: one row per batched transition (contract id, document type, +//! action, and for token transitions the amount and recipient), +//! - `IdentityUpdate`: every key added (purpose, level, bounds, limits) and +//! every key disabled, +//! - `IdentityCreditTransfer`: recipient and amount, +//! - `DataContractCreate` / `DataContractUpdate`: contract id and document +//! type names, +//! - everything else: the kind name only. +//! +//! Every result also carries the kind name, the owner id, whether the +//! transition is already signed, and the exact bytes that were decoded (with +//! the variant tag, so the caller can sign what it showed). Nothing is +//! refused on kind: the sheet shows what is asked and the user decides. +//! Kinds without a describer are shown as a structured dump behind an +//! advanced setting on the client side. +//! +//! This module does not sign and does not broadcast. use std::borrow::Cow; +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; use std::slice; +use dpp::prelude::Identifier; use dpp::serialization::PlatformDeserializableUntrusted; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; -use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionTypeGetter; +use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::token_transition_action_type::TokenTransitionActionTypeGetter; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; -use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::v0_methods::DocumentPurchaseTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_burn_transition::v0::v0_methods::TokenBurnTransitionV0Methods; +use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::v0_methods::TokenDestroyFrozenFundsTransitionV0Methods; use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::v0_methods::TokenDirectPurchaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_freeze_transition::v0::v0_methods::TokenFreezeTransitionV0Methods; +use dpp::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; +use dpp::state_transition::batch_transition::token_transfer_transition::v0::v0_methods::TokenTransferTransitionV0Methods; +use dpp::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::{DocumentTransition, TokenTransition}; use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; +use dpp::state_transition::data_contract_update_transition::accessors::DataContractUpdateTransitionAccessorsV0; +use dpp::state_transition::identity_credit_transfer_transition::accessors::IdentityCreditTransferTransitionAccessorsV0; use dpp::state_transition::{StateTransition, StateTransitionOwned}; use crate::check_ptr; @@ -45,8 +71,25 @@ pub(crate) const BATCH_VARIANT_TAG: u8 = 2; pub const PARSED_STATE_TRANSITION_KIND_NONE: u8 = 0; /// `ParsedStateTransitionFFI::kind`: `identity_update` is populated. pub const PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE: u8 = 1; -/// `ParsedStateTransitionFFI::kind`: `token_direct_purchase` is populated. -pub const PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE: u8 = 2; +/// `ParsedStateTransitionFFI::kind`: `batch` is populated. +pub const PARSED_STATE_TRANSITION_KIND_BATCH: u8 = 2; +/// `ParsedStateTransitionFFI::kind`: `credit_transfer` is populated. +pub const PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER: u8 = 3; +/// `ParsedStateTransitionFFI::kind`: `data_contract` is populated and the +/// transition creates the contract. +pub const PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE: u8 = 4; +/// `ParsedStateTransitionFFI::kind`: `data_contract` is populated and the +/// transition updates the contract. +pub const PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE: u8 = 5; +/// `ParsedStateTransitionFFI::kind`: a kind this module has no describer +/// for. Only the common fields (`kind_name`, `owner_id`, `is_signed`, +/// `serialized`) are populated. +pub const PARSED_STATE_TRANSITION_KIND_OTHER: u8 = 255; + +/// `ParsedBatchedTransitionFFI::family`: a document transition. +pub const PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT: u8 = 0; +/// `ParsedBatchedTransitionFFI::family`: a token transition. +pub const PARSED_BATCHED_TRANSITION_FAMILY_TOKEN: u8 = 1; /// Variant tags tried when the payload appears to use a tagless framing. /// `IdentityUpdate` first: it is the framing Yappr has actually been observed @@ -56,67 +99,192 @@ const TAGLESS_FRAMING_CANDIDATES: &[(u8, &str)] = &[ (BATCH_VARIANT_TAG, "Batch"), ]; -/// Owned C representation of the inspectable parts of a token direct -/// purchase carried by a parsed `BatchTransition`. +/// One transition inside a parsed `BatchTransition`. /// -/// Plain old data — no heap allocations, so nothing beyond the containing -/// [`ParsedStateTransitionFFI`] needs freeing. +/// `action` is a Rust-owned NUL-terminated string naming the action as +/// `DocumentTransitionActionType` / `TokenTransitionActionType` spell it +/// (`Create`, `Replace`, `Delete`, `Transfer`, `Purchase`, `UpdatePrice`, +/// `IndexOnlyDelete`; `Burn`, `Mint`, `Transfer`, `Freeze`, `Unfreeze`, +/// `DestroyFrozenFunds`, `Claim`, `EmergencyAction`, `ConfigUpdate`, +/// `DirectPurchase`, `SetPriceForDirectPurchase`). `document_type` is set +/// for document transitions and null for token ones. Both are released by +/// [`platform_wallet_parse_state_transition_free`]. /// -/// Carries everything `platform_wallet_token_purchase` needs to rebuild the -/// purchase after user approval, plus what the user must see before -/// approving (`owner_id` — the identity that will be charged — and the -/// `token_id` the price is quoted for). +/// `has_amount` / `amount` carry the credits or tokens the transition +/// moves: a document purchase price, an update-price value, a token +/// transfer / mint / burn amount, or a direct purchase's total agreed +/// price. `has_recipient` / `recipient_id` carry the identity on the other +/// side: a document transfer's new owner, a token transfer's recipient, a +/// mint's issued-to identity, or the frozen identity of a freeze / unfreeze +/// / destroy. Both are interpreted against `action`: the sheet should say +/// "freeze the tokens of X" for a `Freeze`, not "send to X". +/// `token_contract_position` and `token_id` are set for every token +/// transition. #[repr(C)] -#[derive(Default)] -pub struct ParsedTokenDirectPurchaseFFI { - /// The identity whose credits pay for the purchase. - pub owner_id: [u8; 32], - /// The data contract defining the token. +pub struct ParsedBatchedTransitionFFI { + /// `PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT` or `_TOKEN`. + pub family: u8, + /// Data contract the transition acts within. pub data_contract_id: [u8; 32], - /// The token being bought. - pub token_id: [u8; 32], - /// Position of the token within the contract. + /// Action name, see the struct doc. Owned, NUL-terminated. + pub action: *mut c_char, + /// Document type name for document transitions; null for token ones. + pub document_type: *mut c_char, + /// Document id for document transitions; zero for token ones. + pub document_id: [u8; 32], + /// Position of the token within its contract (token transitions). pub token_contract_position: u16, - /// How many tokens the dApp asks to buy. - pub token_count: u64, - /// Credits the owner would agree to pay in total. - pub total_agreed_price: u64, + /// Token id (token transitions); zero for document ones. + pub token_id: [u8; 32], + pub has_amount: bool, + pub amount: u64, + pub has_recipient: bool, + pub recipient_id: [u8; 32], } -/// Owned C representation of one parsed state transition, discriminated by -/// `kind`. Exactly one payload field is populated; the other stays in its -/// zeroed default state. Must be released via -/// [`platform_wallet_parse_state_transition_free`] regardless of `kind` -/// (freeing a default / token-purchase value is a safe no-op). +impl Default for ParsedBatchedTransitionFFI { + fn default() -> Self { + Self { + family: PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT, + data_contract_id: [0u8; 32], + action: ptr::null_mut(), + document_type: ptr::null_mut(), + document_id: [0u8; 32], + token_contract_position: 0, + token_id: [0u8; 32], + has_amount: false, + amount: 0, + has_recipient: false, + recipient_id: [0u8; 32], + } + } +} + +/// Owned C representation of a parsed `BatchTransition`: its owner and one +/// [`ParsedBatchedTransitionFFI`] per batched transition, in order. +#[repr(C)] +pub struct ParsedBatchFFI { + pub owner_id: [u8; 32], + pub transitions: *mut ParsedBatchedTransitionFFI, + pub transitions_count: usize, +} + +impl Default for ParsedBatchFFI { + fn default() -> Self { + Self { + owner_id: [0u8; 32], + transitions: ptr::null_mut(), + transitions_count: 0, + } + } +} + +/// Owned C representation of a parsed `IdentityCreditTransferTransition`. +/// Plain old data. #[repr(C)] #[derive(Default)] +pub struct ParsedCreditTransferFFI { + pub identity_id: [u8; 32], + pub recipient_id: [u8; 32], + pub amount: u64, +} + +/// Owned C representation of the inspectable parts of a parsed +/// `DataContractCreateTransition` or `DataContractUpdateTransition`. +/// `document_type_names` is a Rust-owned array of NUL-terminated strings, +/// sorted as the contract stores them. +#[repr(C)] +pub struct ParsedDataContractFFI { + pub contract_id: [u8; 32], + pub owner_id: [u8; 32], + pub document_type_names: *mut *mut c_char, + pub document_type_names_count: usize, +} + +impl Default for ParsedDataContractFFI { + fn default() -> Self { + Self { + contract_id: [0u8; 32], + owner_id: [0u8; 32], + document_type_names: ptr::null_mut(), + document_type_names_count: 0, + } + } +} + +/// Owned C representation of one parsed state transition, discriminated by +/// `kind`. Exactly one of the kind-specific payload fields is populated +/// (none for `PARSED_STATE_TRANSITION_KIND_OTHER`); the rest stay in their +/// zeroed default state. The common fields are populated for every kind. +/// Must be released via [`platform_wallet_parse_state_transition_free`] +/// regardless of `kind`. +#[repr(C)] pub struct ParsedStateTransitionFFI { /// One of the `PARSED_STATE_TRANSITION_KIND_*` constants. pub kind: u8, + /// `StateTransition::name()` of the decoded transition, e.g. + /// `IdentityUpdate`, `DocumentsBatch([Create, TokenTransfer])`, + /// `MasternodeVote`. Owned, NUL-terminated. + pub kind_name: *mut c_char, + /// Whether the transition names an owner (every kind except the + /// asset-lock-funded and shielded ones does). + pub has_owner_id: bool, + /// The identity the transition acts for, when `has_owner_id`. + pub owner_id: [u8; 32], + /// Whether the transition already carries a non-empty signature. A + /// `sign` request must arrive unsigned. + pub is_signed: bool, + /// The bytes that were decoded, always in tagged framing (the variant + /// tag was prepended when the input arrived tagless). These, not the + /// input, are what a caller should sign after approval. + pub serialized: *mut u8, + pub serialized_len: usize, /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE`. pub identity_update: ParsedIdentityUpdateFFI, - /// Populated when - /// `kind == PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE`. - pub token_direct_purchase: ParsedTokenDirectPurchaseFFI, + /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_BATCH`. + pub batch: ParsedBatchFFI, + /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER`. + pub credit_transfer: ParsedCreditTransferFFI, + /// Populated when `kind` is `_DATA_CONTRACT_CREATE` or `_UPDATE`. + pub data_contract: ParsedDataContractFFI, +} + +impl Default for ParsedStateTransitionFFI { + fn default() -> Self { + Self { + kind: PARSED_STATE_TRANSITION_KIND_NONE, + kind_name: ptr::null_mut(), + has_owner_id: false, + owner_id: [0u8; 32], + is_signed: false, + serialized: ptr::null_mut(), + serialized_len: 0, + identity_update: ParsedIdentityUpdateFFI::default(), + batch: ParsedBatchFFI::default(), + credit_transfer: ParsedCreditTransferFFI::default(), + data_contract: ParsedDataContractFFI::default(), + } + } } /// Deserializes `bytes` as a `StateTransition`, tolerating both normal /// tagged DPP framing and the tagless framing Yappr sends, where the /// positional bincode enum variant tag has to be prepended first. /// -/// A leading known variant tag usually means the payload is already framed -/// as a state transition, and a tagless body needs one of the `candidates` -/// tags prepended. Neither test is conclusive — a tagless body can start -/// with a tag byte by coincidence — so the likelier framing is only tried -/// first, and the others are still tried before the payload is rejected. +/// Every framing is tried: the bytes as they are, and the bytes with each +/// of the `candidates` tags prepended. Exactly one must decode. A payload +/// that decodes under two framings is refused rather than described under +/// whichever was tried first: the caller signs what the user was shown, +/// so an ambiguous payload could otherwise be approved as one transition +/// and broadcast as another. In practice a tagless body only decodes with +/// its own tag prepended and a tagged body only decodes as-is, so the +/// happy path has exactly one hit. +/// +/// Returns the transition together with the bytes that decoded it (tagged). pub(crate) fn deserialize_transition_with_flexible_framing( bytes: &[u8], candidates: &[(u8, &str)], -) -> Result { - let leads_with_candidate_tag = bytes - .first() - .is_some_and(|first| candidates.iter().any(|(tag, _)| tag == first)); - +) -> Result<(StateTransition, Vec), PlatformWalletFFIResult> { let as_is: (Cow<'_, [u8]>, String) = (Cow::Borrowed(bytes), "as-is".to_string()); let prepended = candidates.iter().map(|(tag, name)| { let mut prefixed = Vec::with_capacity(bytes.len() + 1); @@ -127,126 +295,327 @@ pub(crate) fn deserialize_transition_with_flexible_framing( format!("{name} variant tag prepended"), ) }); + let attempts: Vec<(Cow<'_, [u8]>, String)> = std::iter::once(as_is).chain(prepended).collect(); - let mut attempts: Vec<(Cow<'_, [u8]>, String)> = Vec::with_capacity(candidates.len() + 1); - if leads_with_candidate_tag { - attempts.push(as_is); - attempts.extend(prepended); - } else { - attempts.extend(prepended); - attempts.push(as_is); - } - + let mut decoded: Vec<(StateTransition, Vec, &str)> = Vec::new(); let mut failures: Vec = Vec::with_capacity(attempts.len()); for (payload, label) in &attempts { match StateTransition::deserialize_from_bytes_untrusted(payload) { - Ok(state_transition) => return Ok(state_transition), + Ok(state_transition) => decoded.push((state_transition, payload.to_vec(), label)), Err(error) => failures.push(format!("{label}: {error}")), } } - Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorDeserialization, - format!( - "Failed to deserialize state transition in any supported framing ({})", - failures.join("; ") - ), - )) + match decoded.len() { + 1 => { + let (transition, payload, _) = decoded.remove(0); + Ok((transition, payload)) + } + 0 => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!( + "Failed to deserialize state transition in any supported framing ({})", + failures.join("; ") + ), + )), + _ => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!( + "Ambiguous state transition framing: the bytes decode under more than one \ + framing ({}); refusing to guess which one the sender meant", + decoded + .iter() + .map(|(transition, _, label)| format!("{label} as {}", transition.name())) + .collect::>() + .join(", ") + ), + )), + } } -/// Projects the single `TokenDirectPurchase` out of `batch`. -/// -/// A batch may in principle carry several transitions, but anything other -/// than exactly one `TokenDirectPurchase` is rejected: the approval sheet -/// shows the user one purchase ("buy N of token T for P credits"), so a -/// multi-transition or mixed batch would execute more than the user -/// approved. The rebuild path (`platform_wallet_token_purchase`) can also -/// only reproduce a single purchase, so a wider batch could not be signed -/// faithfully even after approval. +/// A string that cannot cross the FFI as a C string is an error rather than +/// a fallback: showing the user a different name than the transition +/// declares would let them approve something other than what they saw. /// -/// `batch_description` is `StateTransition::name()` for the whole batch — -/// it lists the kinds of every inner transition, which makes the rejection -/// messages actionable without this module enumerating every batched -/// transition variant itself. -fn project_parsed_token_direct_purchase( - batch: &BatchTransition, - batch_description: &str, -) -> Result { - let transitions_len = batch.transitions_len(); - if transitions_len != 1 { - return Err(PlatformWalletFFIResult::err( +/// Returns the owning [`CString`]; callers `into_raw()` it only once every +/// string of the row they are building has converted, so a failure partway +/// through drops what was built instead of leaking a raw pointer. +fn owned_c_string(value: &str, what: &str) -> Result { + CString::new(value).map_err(|error| { + PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Refusing to parse a batch of {transitions_len} transitions as a token \ - purchase — the user can only approve exactly one TokenDirectPurchase \ - ({batch_description})" - ), - )); + format!("{what} cannot be represented as a C string: {error}"), + ) + }) +} + +unsafe fn free_c_string(ptr: &mut *mut c_char) { + if !ptr.is_null() { + drop(CString::from_raw(*ptr)); + *ptr = ptr::null_mut(); } +} - let Some(BatchedTransitionRef::Token(TokenTransition::DirectPurchase(purchase))) = - batch.first_transition() - else { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Expected the batch to carry a single TokenDirectPurchase transition, \ - got {batch_description}" - ), - )); +fn project_document_transition( + transition: &DocumentTransition, +) -> Result { + let (amount, recipient) = match transition { + DocumentTransition::Transfer(t) => (None, Some(t.recipient_owner_id())), + DocumentTransition::Purchase(t) => (Some(t.price()), None), + DocumentTransition::UpdatePrice(t) => (Some(t.price()), None), + DocumentTransition::Create(_) + | DocumentTransition::Replace(_) + | DocumentTransition::Delete(_) + | DocumentTransition::IndexOnlyDelete(_) => (None, None), }; + let action = owned_c_string( + &format!("{:?}", transition.action_type()), + "Batched document transition action", + )?; + let document_type = owned_c_string( + transition.document_type_name(), + "Batched document transition document type", + )?; - let base = purchase.base(); + Ok(ParsedBatchedTransitionFFI { + family: PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT, + data_contract_id: transition.data_contract_id().to_buffer(), + action: action.into_raw(), + document_type: document_type.into_raw(), + document_id: transition.get_id().to_buffer(), + has_amount: amount.is_some(), + amount: amount.unwrap_or_default(), + has_recipient: recipient.is_some(), + recipient_id: recipient.map(|id| id.to_buffer()).unwrap_or_default(), + ..ParsedBatchedTransitionFFI::default() + }) +} - // DirectPurchase is not group-gated and the rebuild path submits it - // without group info, so a payload that smuggles group info in would be - // approved as one thing and signed as another. Reject it instead. - if base.using_group_info().is_some() { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "TokenDirectPurchase carries group-action info, which the wallet's \ - rebuild-and-sign purchase path does not support" - .to_string(), - )); +fn project_token_transition( + transition: &TokenTransition, +) -> Result { + let (amount, recipient): (Option, Option) = match transition { + TokenTransition::Transfer(t) => (Some(t.amount()), Some(t.recipient_id())), + TokenTransition::Mint(t) => (Some(t.amount()), t.issued_to_identity_id()), + TokenTransition::Burn(t) => (Some(t.burn_amount()), None), + TokenTransition::Freeze(t) => (None, Some(t.frozen_identity_id())), + TokenTransition::Unfreeze(t) => (None, Some(t.frozen_identity_id())), + TokenTransition::DestroyFrozenFunds(t) => (None, Some(t.frozen_identity_id())), + TokenTransition::DirectPurchase(t) => (Some(t.total_agreed_price()), None), + TokenTransition::Claim(_) + | TokenTransition::EmergencyAction(_) + | TokenTransition::ConfigUpdate(_) + | TokenTransition::SetPriceForDirectPurchase(_) => (None, None), + }; + let action = owned_c_string( + &transition.action_type().to_string(), + "Batched token transition action", + )?; + let base = transition.base(); + + Ok(ParsedBatchedTransitionFFI { + family: PARSED_BATCHED_TRANSITION_FAMILY_TOKEN, + data_contract_id: transition.data_contract_id().to_buffer(), + action: action.into_raw(), + document_type: ptr::null_mut(), + token_contract_position: base.token_contract_position(), + token_id: transition.token_id().to_buffer(), + has_amount: amount.is_some(), + amount: amount.unwrap_or_default(), + has_recipient: recipient.is_some(), + recipient_id: recipient.map(|id| id.to_buffer()).unwrap_or_default(), + ..ParsedBatchedTransitionFFI::default() + }) +} + +unsafe fn free_batched_transitions(rows: &mut [ParsedBatchedTransitionFFI]) { + for row in rows.iter_mut() { + free_c_string(&mut row.action); + free_c_string(&mut row.document_type); + } +} + +fn project_parsed_batch( + batch: &BatchTransition, +) -> Result { + let mut rows: Vec = Vec::with_capacity(batch.transitions_len()); + for transition in batch.transitions_iter() { + let projected = match transition { + BatchedTransitionRef::Document(document) => project_document_transition(document), + BatchedTransitionRef::Token(token) => project_token_transition(token), + }; + match projected { + Ok(row) => rows.push(row), + Err(error) => { + // The caller never receives this struct, so nothing else will + // free the rows projected so far. + unsafe { free_batched_transitions(&mut rows) }; + return Err(error); + } + } } - Ok(ParsedTokenDirectPurchaseFFI { + let transitions_count = rows.len(); + let transitions = if transitions_count == 0 { + ptr::null_mut() + } else { + Box::into_raw(rows.into_boxed_slice()) as *mut ParsedBatchedTransitionFFI + }; + + Ok(ParsedBatchFFI { owner_id: batch.owner_id().to_buffer(), - data_contract_id: base.data_contract_id().to_buffer(), - token_id: base.token_id().to_buffer(), - token_contract_position: base.token_contract_position(), - token_count: purchase.token_count(), - total_agreed_price: purchase.total_agreed_price(), + transitions, + transitions_count, }) } +unsafe fn free_parsed_batch(batch: &mut ParsedBatchFFI) { + if !batch.transitions.is_null() && batch.transitions_count > 0 { + let rows = slice::from_raw_parts_mut(batch.transitions, batch.transitions_count); + free_batched_transitions(rows); + drop(Box::from_raw(rows as *mut [ParsedBatchedTransitionFFI])); + } + *batch = ParsedBatchFFI::default(); +} + +fn project_parsed_data_contract( + contract: &dpp::data_contract::serialized_version::DataContractInSerializationFormat, +) -> Result { + // `Vec` owns every name until all of them have converted, so a + // failure partway through frees what was built with no manual cleanup. + let mut names: Vec = Vec::with_capacity(contract.document_schemas().len()); + for name in contract.document_schemas().keys() { + names.push(owned_c_string(name, "Data contract document type name")?); + } + + let document_type_names_count = names.len(); + let document_type_names = if document_type_names_count == 0 { + ptr::null_mut() + } else { + let raw: Vec<*mut c_char> = names.into_iter().map(CString::into_raw).collect(); + Box::into_raw(raw.into_boxed_slice()) as *mut *mut c_char + }; + + Ok(ParsedDataContractFFI { + contract_id: contract.id().to_buffer(), + owner_id: contract.owner_id().to_buffer(), + document_type_names, + document_type_names_count, + }) +} + +unsafe fn free_parsed_data_contract(contract: &mut ParsedDataContractFFI) { + if !contract.document_type_names.is_null() && contract.document_type_names_count > 0 { + let names = slice::from_raw_parts_mut( + contract.document_type_names, + contract.document_type_names_count, + ); + for name in names.iter_mut() { + free_c_string(name); + } + drop(Box::from_raw(names as *mut [*mut c_char])); + } + *contract = ParsedDataContractFFI::default(); +} + +/// Projects a decoded transition into the C struct. Everything allocated +/// is owned by `out` on success; on failure nothing is left allocated. +fn project_parsed_state_transition( + transition: &StateTransition, + serialized: Vec, +) -> Result { + let mut out = ParsedStateTransitionFFI::default(); + + let payload = match transition { + StateTransition::IdentityUpdate(identity_update) => { + project_parsed_identity_update(identity_update).map(|parsed| { + out.identity_update = parsed; + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE + }) + } + StateTransition::Batch(batch) => project_parsed_batch(batch).map(|parsed| { + out.batch = parsed; + PARSED_STATE_TRANSITION_KIND_BATCH + }), + StateTransition::IdentityCreditTransfer(transfer) => { + out.credit_transfer = ParsedCreditTransferFFI { + identity_id: transfer.identity_id().to_buffer(), + recipient_id: transfer.recipient_id().to_buffer(), + amount: transfer.amount(), + }; + Ok(PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER) + } + StateTransition::DataContractCreate(create) => { + project_parsed_data_contract(create.data_contract()).map(|parsed| { + out.data_contract = parsed; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + }) + } + StateTransition::DataContractUpdate(update) => { + project_parsed_data_contract(update.data_contract()).map(|parsed| { + out.data_contract = parsed; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE + }) + } + _ => Ok(PARSED_STATE_TRANSITION_KIND_OTHER), + }; + let kind = match payload { + Ok(kind) => kind, + Err(error) => { + // Nothing kind-specific was stored on a failure, so only the + // default (empty) payloads are released here. + unsafe { free_parsed_state_transition_payloads(&mut out) }; + return Err(error); + } + }; + + let kind_name = match owned_c_string(&transition.name(), "State transition kind name") { + Ok(name) => name.into_raw(), + Err(error) => { + unsafe { free_parsed_state_transition_payloads(&mut out) }; + return Err(error); + } + }; + + let owner_id = transition.owner_id(); + let serialized_len = serialized.len(); + let serialized_ptr = Box::into_raw(serialized.into_boxed_slice()) as *mut u8; + + out.kind = kind; + out.kind_name = kind_name; + out.has_owner_id = owner_id.is_some(); + out.owner_id = owner_id.map(|id| id.to_buffer()).unwrap_or_default(); + out.is_signed = transition.signature().is_some_and(|sig| !sig.is_empty()); + out.serialized = serialized_ptr; + out.serialized_len = serialized_len; + Ok(out) +} + +unsafe fn free_parsed_state_transition_payloads(parsed: &mut ParsedStateTransitionFFI) { + platform_wallet_parse_identity_update_transition_free(&mut parsed.identity_update); + free_parsed_batch(&mut parsed.batch); + free_parsed_data_contract(&mut parsed.data_contract); + parsed.credit_transfer = ParsedCreditTransferFFI::default(); +} + /// Deserializes a raw DPP state transition (as carried by a DashConnect -/// `dash-st:` link / QR) into its inspectable parts, reporting which -/// supported kind it found in `out.kind` so the caller can branch without -/// probing kind-specific parsers and branching on their errors. -/// -/// Supported kinds: -/// - `IdentityUpdateTransition` (DashConnect key registration) → -/// `PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE`, `identity_update` -/// populated exactly as by -/// `platform_wallet_parse_identity_update_transition`. -/// - `BatchTransition` carrying exactly one `TokenDirectPurchase` (a dApp -/// token purchase) → -/// `PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE`, -/// `token_direct_purchase` populated. +/// `dash-st:` link / QR or a DashPay Connect `sign` request) into its +/// inspectable parts, reporting which kind it found in `out.kind` so the +/// caller can branch without probing kind-specific parsers. /// -/// Every other transition kind — including batches that are empty, carry -/// several transitions, or carry anything other than a direct purchase — is -/// rejected with `ErrorInvalidParameter` naming what was found. +/// Every kind decodes. `IdentityUpdate`, `Batch`, `IdentityCreditTransfer` +/// and the two data contract transitions get a typed summary (see the +/// module doc); every other kind is reported as +/// `PARSED_STATE_TRANSITION_KIND_OTHER` with the common fields only +/// (`kind_name`, `owner_id`, `is_signed`, `serialized`), so the client can +/// show a structured dump rather than refuse. /// /// Accepts both normal tagged DPP state-transition bytes and Yappr's /// tagless framing, where the positional bincode enum variant tag has to be -/// prepended before deserialization. +/// prepended before deserialization; `out.serialized` always holds the +/// tagged bytes that actually decoded. /// -/// Does NOT sign and does NOT broadcast — the caller shows the parsed -/// intent to the user and rebuilds the operation through the normal signing -/// path (`platform_wallet_token_purchase` / -/// `platform_wallet_update_identity_with_signer`). +/// Does NOT sign and does NOT broadcast. #[no_mangle] pub unsafe extern "C" fn platform_wallet_parse_state_transition( transition_bytes: *const u8, @@ -259,41 +628,18 @@ pub unsafe extern "C" fn platform_wallet_parse_state_transition( *out = ParsedStateTransitionFFI::default(); let bytes = slice::from_raw_parts(transition_bytes, transition_len); - let transition = unwrap_result_or_return!(deserialize_transition_with_flexible_framing( - bytes, - TAGLESS_FRAMING_CANDIDATES, - )); + let (transition, serialized) = unwrap_result_or_return!( + deserialize_transition_with_flexible_framing(bytes, TAGLESS_FRAMING_CANDIDATES) + ); - match &transition { - StateTransition::IdentityUpdate(identity_update) => { - (*out).identity_update = - unwrap_result_or_return!(project_parsed_identity_update(identity_update)); - (*out).kind = PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE; - } - StateTransition::Batch(batch) => { - (*out).token_direct_purchase = unwrap_result_or_return!( - project_parsed_token_direct_purchase(batch, &transition.name()) - ); - (*out).kind = PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE; - } - other => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Unsupported state transition kind for dApp intent parsing: {}", - other.name() - ), - ); - } - } + *out = unwrap_result_or_return!(project_parsed_state_transition(&transition, serialized)); PlatformWalletFFIResult::ok() } /// Frees a parsed transition previously returned by /// [`platform_wallet_parse_state_transition`]. Safe to call for any `kind`, -/// including the zeroed default — only the identity-update payload owns -/// heap allocations. +/// including the zeroed default, and idempotent. #[no_mangle] pub unsafe extern "C" fn platform_wallet_parse_state_transition_free( out: *mut ParsedStateTransitionFFI, @@ -303,36 +649,64 @@ pub unsafe extern "C" fn platform_wallet_parse_state_transition_free( } let parsed = &mut *out; - platform_wallet_parse_identity_update_transition_free(&mut parsed.identity_update); + free_parsed_state_transition_payloads(parsed); + free_c_string(&mut parsed.kind_name); + if !parsed.serialized.is_null() && parsed.serialized_len > 0 { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + parsed.serialized, + parsed.serialized_len, + ))); + } *parsed = ParsedStateTransitionFFI::default(); } #[cfg(test)] mod tests { use super::*; - use dpp::group::GroupStateTransitionInfo; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::serialized_version::DataContractInSerializationFormat; + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::{KeyType, Purpose, SecurityLevel}; - use dpp::platform_value::BinaryData; + use dpp::platform_value::{platform_value, BinaryData}; use dpp::prelude::Identifier; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use dpp::state_transition::batch_transition::document_create_transition::v0::DocumentCreateTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::DocumentTransferTransition; use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; - use dpp::state_transition::batch_transition::token_burn_transition::v0::TokenBurnTransitionV0; use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::TokenDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; use dpp::state_transition::batch_transition::{ - BatchTransitionV1, TokenBurnTransition, TokenDirectPurchaseTransition, + BatchTransitionV1, DocumentCreateTransition, TokenDirectPurchaseTransition, + TokenTransferTransition, }; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; + use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::TryFromPlatformVersioned; use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + use std::ffi::CStr; + + const OWNER: [u8; 32] = [0x21; 32]; + const CONTRACT: [u8; 32] = [0x42; 32]; + const TOKEN: [u8; 32] = [0x77; 32]; + const RECIPIENT: [u8; 32] = [0x22; 32]; - fn purchase_base() -> TokenBaseTransition { + fn token_base() -> TokenBaseTransition { TokenBaseTransition::V0(TokenBaseTransitionV0 { identity_contract_nonce: 4, token_contract_position: 3, - data_contract_id: Identifier::from([0x42; 32]), - token_id: Identifier::from([0x77; 32]), + data_contract_id: Identifier::from(CONTRACT), + token_id: Identifier::from(TOKEN), using_group_info: None, }) } @@ -340,20 +714,63 @@ mod tests { fn direct_purchase() -> BatchedTransition { BatchedTransition::Token(TokenTransition::DirectPurchase( TokenDirectPurchaseTransition::V0(TokenDirectPurchaseTransitionV0 { - base: purchase_base(), + base: token_base(), token_count: 100, total_agreed_price: 100_000_000, }), )) } - fn batch_transition_bytes(transitions: Vec) -> Vec { + fn token_transfer() -> BatchedTransition { + BatchedTransition::Token(TokenTransition::Transfer(TokenTransferTransition::V0( + TokenTransferTransitionV0 { + base: token_base(), + amount: 250, + recipient_id: Identifier::from(RECIPIENT), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + }, + ))) + } + + fn document_base(document_type_name: &str) -> DocumentBaseTransition { + DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([0x0D; 32]), + identity_contract_nonce: 1, + document_type_name: document_type_name.to_string(), + data_contract_id: Identifier::from(CONTRACT), + }) + } + + fn document_create(document_type_name: &str) -> BatchedTransition { + BatchedTransition::Document(DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base(document_type_name), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), platform_value!("hi"))]), + prefunded_voting_balance: None, + }, + ))) + } + + fn document_transfer() -> BatchedTransition { + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base: document_base("profile"), + revision: 2, + recipient_owner_id: Identifier::from(RECIPIENT), + }), + )) + } + + fn batch_transition_bytes(transitions: Vec, signature: Vec) -> Vec { StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { - owner_id: Identifier::from([0x21; 32]), + owner_id: Identifier::from(OWNER), transitions, user_fee_increase: 1, signature_public_key_id: 2, - signature: BinaryData::new(vec![0x88; 65]), + signature: BinaryData::new(signature), })) .serialize_to_bytes() .expect("fixture batch serializes") @@ -367,17 +784,36 @@ mod tests { identity_id: Identifier::from([0x11; 32]), revision: 7, nonce: 9, - add_public_keys: vec![IdentityPublicKeyInCreationV0 { - id: 17, - key_type: KeyType::ECDSA_SECP256K1, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - read_only: false, - data: BinaryData::new(vec![0x02; 33]), - signature: BinaryData::new(vec![0xaa; 65]), - contract_bounds: None, - } - .into()], + add_public_keys: vec![ + IdentityPublicKeyInCreationV0 { + id: 17, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + read_only: false, + data: BinaryData::new(vec![0x02; 33]), + signature: BinaryData::new(vec![0xaa; 65]), + contract_bounds: None, + } + .into(), + // A DashPay Connect session key: HIGH auth key bound to a + // contract group with a budget and an expiry. + IdentityPublicKeyInCreationV1 { + id: 18, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + read_only: false, + data: BinaryData::new(vec![0x03; 33]), + signature: BinaryData::new(vec![0xbb; 65]), + contract_bounds: Some(ContractBounds::ContractGroup { + id: Identifier::from([0x66; 32]), + }), + total_budget: Some(10_000_000_000), + expires_at: Some(1_800_000_000_000), + } + .into(), + ], disable_public_keys: vec![4, 8], user_fee_increase: 2, } @@ -387,6 +823,78 @@ mod tests { .expect("fixture identity update serializes") } + fn credit_transfer_bytes() -> Vec { + StateTransition::IdentityCreditTransfer( + IdentityCreditTransferTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture credit transfer serializes") + } + + /// The rs-dpp fixture contract (owner `OWNER`), in the wire form a + /// create / update transition carries. Its id and document type names + /// are read back from the fixture rather than hard-coded so the test + /// follows the fixture if it changes. + fn contract_format() -> (DataContractInSerializationFormat, Identifier, Vec) { + let created = get_data_contract_fixture( + Some(Identifier::from(OWNER)), + 1, + PlatformVersion::latest().protocol_version, + ); + let contract = created.data_contract(); + let id = contract.id(); + let names: Vec = contract.document_types().keys().cloned().collect(); + let format = DataContractInSerializationFormat::try_from_platform_versioned( + contract, + PlatformVersion::latest(), + ) + .expect("fixture contract converts to its serialization format"); + (format, id, names) + } + + fn data_contract_create_bytes() -> (Vec, Identifier, Vec) { + let (format, id, names) = contract_format(); + let bytes = StateTransition::DataContractCreate( + DataContractCreateTransitionV0 { + data_contract: format, + identity_nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture contract create serializes"); + (bytes, id, names) + } + + fn data_contract_update_bytes() -> (Vec, Identifier, Vec) { + let (format, id, names) = contract_format(); + let bytes = StateTransition::DataContractUpdate( + DataContractUpdateTransitionV0 { + identity_contract_nonce: 2, + data_contract: format, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture contract update serializes"); + (bytes, id, names) + } + fn parse(bytes: &[u8]) -> (PlatformWalletFFIResult, ParsedStateTransitionFFI) { let mut out = ParsedStateTransitionFFI::default(); let result = unsafe { @@ -395,31 +903,109 @@ mod tests { (result, out) } + unsafe fn c_str(ptr: *const c_char) -> String { + CStr::from_ptr(ptr).to_str().expect("utf8").to_string() + } + + unsafe fn batched_rows(out: &ParsedStateTransitionFFI) -> &[ParsedBatchedTransitionFFI] { + slice::from_raw_parts(out.batch.transitions, out.batch.transitions_count) + } + + unsafe fn serialized(out: &ParsedStateTransitionFFI) -> &[u8] { + slice::from_raw_parts(out.serialized, out.serialized_len) + } + #[test] - fn parses_a_tagged_token_direct_purchase_batch() { - let bytes = batch_transition_bytes(vec![direct_purchase()]); + fn parses_a_mixed_batch_with_one_row_per_transition() { + let bytes = batch_transition_bytes( + vec![ + document_create("post"), + document_transfer(), + token_transfer(), + direct_purchase(), + ], + vec![0x88; 65], + ); let (result, mut out) = parse(&bytes); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE); - assert_eq!(out.token_direct_purchase.owner_id, [0x21; 32]); - assert_eq!(out.token_direct_purchase.data_contract_id, [0x42; 32]); - assert_eq!(out.token_direct_purchase.token_id, [0x77; 32]); - assert_eq!(out.token_direct_purchase.token_contract_position, 3); - assert_eq!(out.token_direct_purchase.token_count, 100); - assert_eq!(out.token_direct_purchase.total_agreed_price, 100_000_000); - // The unused payload stays in its default state. + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!( + unsafe { c_str(out.kind_name) }, + "DocumentsBatch([Create, Transfer, TokenTransfer, TokenDirectPurchase])" + ); + assert!(out.has_owner_id); + assert_eq!(out.owner_id, OWNER); + assert!(out.is_signed); + assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + assert_eq!(out.batch.owner_id, OWNER); + assert_eq!(out.batch.transitions_count, 4); + + let rows = unsafe { batched_rows(&out) }; + + let create = &rows[0]; + assert_eq!(create.family, PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT); + assert_eq!(unsafe { c_str(create.action) }, "Create"); + assert_eq!(unsafe { c_str(create.document_type) }, "post"); + assert_eq!(create.data_contract_id, CONTRACT); + assert_eq!(create.document_id, [0x0D; 32]); + assert!(!create.has_amount); + assert!(!create.has_recipient); + + let transfer = &rows[1]; + assert_eq!(unsafe { c_str(transfer.action) }, "Transfer"); + assert_eq!(unsafe { c_str(transfer.document_type) }, "profile"); + assert!(transfer.has_recipient); + assert_eq!(transfer.recipient_id, RECIPIENT); + + let token_transfer = &rows[2]; + assert_eq!( + token_transfer.family, + PARSED_BATCHED_TRANSITION_FAMILY_TOKEN + ); + assert_eq!(unsafe { c_str(token_transfer.action) }, "Transfer"); + assert!(token_transfer.document_type.is_null()); + assert_eq!(token_transfer.data_contract_id, CONTRACT); + assert_eq!(token_transfer.token_id, TOKEN); + assert_eq!(token_transfer.token_contract_position, 3); + assert!(token_transfer.has_amount); + assert_eq!(token_transfer.amount, 250); + assert!(token_transfer.has_recipient); + assert_eq!(token_transfer.recipient_id, RECIPIENT); + + let purchase = &rows[3]; + assert_eq!(unsafe { c_str(purchase.action) }, "DirectPurchase"); + assert!(purchase.has_amount); + assert_eq!(purchase.amount, 100_000_000); + assert!(!purchase.has_recipient); + + // The unused payloads stay in their default state. assert!(out.identity_update.add_public_keys.is_null()); - assert_eq!(out.identity_update.add_public_keys_count, 0); + assert_eq!(out.credit_transfer.amount, 0); + assert!(out.data_contract.document_type_names.is_null()); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); - assert_eq!(out.token_direct_purchase.token_count, 0); + assert!(out.kind_name.is_null()); + assert!(out.serialized.is_null()); + assert!(out.batch.transitions.is_null()); + assert_eq!(out.batch.transitions_count, 0); + } + + #[test] + fn an_empty_batch_and_an_unsigned_batch_both_decode() { + let (result, mut out) = parse(&batch_transition_bytes(vec![], vec![])); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!(out.batch.transitions_count, 0); + assert!(out.batch.transitions.is_null()); + assert!(!out.is_signed); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; } #[test] - fn parses_a_tagless_token_purchase_by_prepending_the_batch_tag() { - let tagged = batch_transition_bytes(vec![direct_purchase()]); + fn parses_a_tagless_batch_by_prepending_the_batch_tag_and_reports_tagged_bytes() { + let tagged = batch_transition_bytes(vec![direct_purchase()], vec![0x88; 65]); assert_eq!( tagged[0], BATCH_VARIANT_TAG, "StateTransition::Batch variant tag drifted" @@ -429,22 +1015,26 @@ mod tests { let (result, mut out) = parse(&tagless); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE); - assert_eq!(out.token_direct_purchase.token_count, 100); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!(unsafe { serialized(&out) }, tagged.as_slice()); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; } #[test] - fn parses_an_identity_update_and_reports_its_kind() { + fn parses_an_identity_update_with_key_limits_and_bounds() { let bytes = identity_update_transition_bytes(); let (result, mut out) = parse(&bytes); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE); + assert_eq!(unsafe { c_str(out.kind_name) }, "IdentityUpdate"); + assert_eq!(out.owner_id, [0x11; 32]); + assert!(out.is_signed); assert_eq!(out.identity_update.identity_id, [0x11; 32]); - assert_eq!(out.identity_update.add_public_keys_count, 1); + assert_eq!(out.identity_update.add_public_keys_count, 2); assert_eq!(out.identity_update.disable_public_key_ids_count, 2); + let keys = unsafe { slice::from_raw_parts( out.identity_update.add_public_keys, @@ -452,122 +1042,297 @@ mod tests { ) }; assert_eq!(keys[0].key_id, 17); - // The unused payload stays in its default state. - assert_eq!(out.token_direct_purchase.token_count, 0); + assert!(!keys[0].has_total_budget); + assert!(!keys[0].has_expires_at); + + assert_eq!(keys[1].key_id, 18); + assert_eq!(keys[1].purpose, Purpose::AUTHENTICATION as u8); + assert_eq!(keys[1].security_level, SecurityLevel::HIGH as u8); + assert_eq!(keys[1].contract_bounds_kind, 3); + assert_eq!(keys[1].contract_bounds_id, [0x66; 32]); + assert!(keys[1].has_total_budget); + assert_eq!(keys[1].total_budget, 10_000_000_000); + assert!(keys[1].has_expires_at); + assert_eq!(keys[1].expires_at, 1_800_000_000_000); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); assert!(out.identity_update.add_public_keys.is_null()); - assert_eq!(out.identity_update.add_public_keys_count, 0); } #[test] - fn rejects_a_batch_with_more_than_one_transition() { - let bytes = batch_transition_bytes(vec![direct_purchase(), direct_purchase()]); - let (result, out) = parse(&bytes); + fn parses_a_credit_transfer() { + let bytes = credit_transfer_bytes(); + let (result, mut out) = parse(&bytes); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter - ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER); + assert_eq!(unsafe { c_str(out.kind_name) }, "IdentityCreditTransfer"); + assert_eq!(out.owner_id, [0x11; 32]); + assert!(!out.is_signed); + assert_eq!(out.credit_transfer.identity_id, [0x11; 32]); + assert_eq!(out.credit_transfer.recipient_id, RECIPIENT); + assert_eq!(out.credit_transfer.amount, 1_000); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert_eq!(out.credit_transfer.amount, 0); } #[test] - fn rejects_an_empty_batch() { - let bytes = batch_transition_bytes(vec![]); - let (result, out) = parse(&bytes); + fn parses_data_contract_create_and_update_with_document_type_names() { + for (fixture, kind, name) in [ + ( + data_contract_create_bytes(), + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE, + "DataContractCreate", + ), + ( + data_contract_update_bytes(), + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE, + "DataContractUpdate", + ), + ] { + let (bytes, contract_id, expected_names) = fixture; + assert!( + !expected_names.is_empty(), + "fixture contract has document types" + ); + let (result, mut out) = parse(&bytes); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter - ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, kind); + assert_eq!(unsafe { c_str(out.kind_name) }, name); + assert_eq!(out.owner_id, OWNER); + assert_eq!(out.data_contract.contract_id, contract_id.to_buffer()); + assert_eq!(out.data_contract.owner_id, OWNER); + assert_eq!( + out.data_contract.document_type_names_count, + expected_names.len() + ); + let names: Vec = unsafe { + slice::from_raw_parts( + out.data_contract.document_type_names, + out.data_contract.document_type_names_count, + ) + .iter() + .map(|name| c_str(*name)) + .collect() + }; + assert_eq!(names, expected_names); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert!(out.data_contract.document_type_names.is_null()); + assert_eq!(out.data_contract.document_type_names_count, 0); + } } #[test] - fn rejects_a_batch_whose_transition_is_not_a_direct_purchase() { - let bytes = batch_transition_bytes(vec![BatchedTransition::Token(TokenTransition::Burn( - TokenBurnTransition::V0(TokenBurnTransitionV0 { - base: purchase_base(), - burn_amount: 5, - public_note: None, - }), - ))]); - let (result, out) = parse(&bytes); + fn reports_kinds_without_a_describer_as_other_with_the_common_fields() { + use dpp::state_transition::masternode_vote_transition::v0::MasternodeVoteTransitionV0; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; + use dpp::voting::votes::resource_vote::ResourceVote; + use dpp::voting::votes::Vote; + let bytes = StateTransition::MasternodeVote( + MasternodeVoteTransitionV0 { + pro_tx_hash: Identifier::from([0x33; 32]), + voter_identity_id: Identifier::from([0x11; 32]), + vote: Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll { + contract_id: Identifier::from(CONTRACT), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![], + }, + ), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + nonce: 1, + signature_public_key_id: 0, + signature: BinaryData::new(vec![0x99; 96]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture masternode vote serializes"); + + let (result, mut out) = parse(&bytes); + + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_OTHER); + assert_eq!(unsafe { c_str(out.kind_name) }, "MasternodeVote"); + assert!(out.has_owner_id); + assert!(out.is_signed); + assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert!(out.kind_name.is_null()); + } + + /// The serialized fixtures the Swift (`ParseStateTransitionTests`) and + /// Kotlin (`StateTransitionParserTest`) suites decode through the same + /// FFI. Pinned as hex so a change to the fixtures or to DPP's wire + /// format is caught here first and the client vectors are updated + /// together. + #[test] + fn fixture_bytes_are_pinned_for_the_client_suites() { + let (contract_create, _, _) = data_contract_create_bytes(); + let (contract_update, _, _) = data_contract_update_bytes(); + let actual = [ + hex::encode(identity_update_transition_bytes()), + hex::encode(batch_transition_bytes( + vec![ + document_create("post"), + document_transfer(), + token_transfer(), + direct_purchase(), + ], + vec![], + )), + hex::encode(credit_transfer_bytes()), + hex::encode(contract_create), + hex::encode(contract_update), + ]; + let expected = [ + FIXTURE_IDENTITY_UPDATE_HEX, + FIXTURE_MIXED_BATCH_HEX, + FIXTURE_CREDIT_TRANSFER_HEX, + FIXTURE_DATA_CONTRACT_CREATE_HEX, + FIXTURE_DATA_CONTRACT_UPDATE_HEX, + ] + .map(|hex| hex.trim().to_string()); assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + actual, expected, + "fixture bytes drifted; update the Swift and Kotlin vectors with these" ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); } + /// The canonical copies live with the Swift tests (`swift test` copies + /// the `Fixtures` directory into the test bundle); the Kotlin test reads + /// them through a relative path. Referencing them from here means the + /// three suites can never disagree about the bytes. Each file is one + /// hex line plus a trailing newline. + macro_rules! client_fixture { + ($name:literal) => { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/", + $name, + ".hex" + )) + }; + } + const FIXTURE_IDENTITY_UPDATE_HEX: &str = client_fixture!("identity_update"); + const FIXTURE_MIXED_BATCH_HEX: &str = client_fixture!("mixed_batch"); + const FIXTURE_CREDIT_TRANSFER_HEX: &str = client_fixture!("credit_transfer"); + const FIXTURE_DATA_CONTRACT_CREATE_HEX: &str = client_fixture!("data_contract_create"); + const FIXTURE_DATA_CONTRACT_UPDATE_HEX: &str = client_fixture!("data_contract_update"); + + /// Tagged bytes of every described kind decode as themselves: the + /// tagless candidates are also tried, and none may happen to decode as + /// well, or the parse would be refused as ambiguous. This is the + /// guarantee the approval sheet rests on: what is described is the + /// transition the bytes carry. #[test] - fn rejects_a_group_gated_direct_purchase() { - let bytes = batch_transition_bytes(vec![BatchedTransition::Token( - TokenTransition::DirectPurchase(TokenDirectPurchaseTransition::V0( - TokenDirectPurchaseTransitionV0 { - base: TokenBaseTransition::V0(TokenBaseTransitionV0 { - identity_contract_nonce: 4, - token_contract_position: 3, - data_contract_id: Identifier::from([0x42; 32]), - token_id: Identifier::from([0x77; 32]), - using_group_info: Some(GroupStateTransitionInfo { - group_contract_position: 1, - action_id: Identifier::from([0x55; 32]), - action_is_proposer: true, - }), - }), - token_count: 100, - total_agreed_price: 100_000_000, - }, - )), - )]); - let (result, out) = parse(&bytes); + fn tagged_bytes_of_every_kind_decode_unambiguously_as_their_own_kind() { + let (contract_create, _, _) = data_contract_create_bytes(); + let (contract_update, _, _) = data_contract_update_bytes(); + let fixtures = [ + ( + identity_update_transition_bytes(), + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE, + ), + ( + batch_transition_bytes(vec![document_create("post"), token_transfer()], vec![]), + PARSED_STATE_TRANSITION_KIND_BATCH, + ), + ( + credit_transfer_bytes(), + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER, + ), + ( + contract_create, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE, + ), + ( + contract_update, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE, + ), + ]; + for (bytes, expected_kind) in fixtures { + let (result, mut out) = parse(&bytes); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, expected_kind); + assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + } + } + /// A payload that decodes under two framings is refused. Built by + /// hand: a tagless identity update whose first body byte happens to be + /// a valid tag would be the real-world case; here the ambiguity is + /// forced by asking the helper to try a candidate that reproduces the + /// as-is bytes. + #[test] + fn ambiguous_framing_is_refused() { + let tagged = credit_transfer_bytes(); + let tagless = tagged[1..].to_vec(); + // Candidate `7` (IdentityCreditTransfer's tag) makes the tagless body + // decode; asking for the same tag twice means two framings decode. + let result = deserialize_transition_with_flexible_framing( + &tagless, + &[ + (tagged[0], "CreditTransfer"), + (tagged[0], "CreditTransfer again"), + ], + ); + let mut error = match result { + Ok(_) => panic!("two decoding framings must be refused"), + Err(error) => error, + }; assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + error.code, + PlatformWalletFFIResultCode::ErrorDeserialization ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + let message = unsafe { CStr::from_ptr(error.message) }.to_str().unwrap(); + assert!(message.contains("Ambiguous"), "{message}"); + unsafe { platform_wallet_ffi_result_free(&mut error) }; } #[test] - fn rejects_an_unsupported_state_transition_kind() { - let bytes = StateTransition::IdentityCreditTransfer( - IdentityCreditTransferTransitionV0 { - identity_id: Identifier::from([0x11; 32]), - recipient_id: Identifier::from([0x22; 32]), - amount: 1_000, - nonce: 1, - user_fee_increase: 0, - signature_public_key_id: 0, - signature: BinaryData::new(vec![0x99; 65]), - } - .into(), - ) - .serialize_to_bytes() - .expect("fixture credit transfer serializes"); + fn rejects_malformed_state_transition_bytes() { + let bytes = [0xde, 0xad, 0xbe, 0xef]; let (result, out) = parse(&bytes); assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + PlatformWalletFFIResultCode::ErrorDeserialization ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.kind_name.is_null()); + assert!(out.serialized.is_null()); } #[test] - fn rejects_malformed_state_transition_bytes() { - let bytes = [0xde, 0xad, 0xbe, 0xef]; + fn rejects_a_document_type_that_cannot_cross_the_ffi_without_leaking() { + // The first row projects and owns two C strings; the second fails, so + // the error path has to release the first row. + let bytes = batch_transition_bytes( + vec![document_create("post"), document_create("pro\0file")], + vec![], + ); let (result, out) = parse(&bytes); assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorDeserialization + PlatformWalletFFIResultCode::ErrorInvalidParameter ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.batch.transitions.is_null()); } #[test] @@ -577,5 +1342,6 @@ mod tests { assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); // Double free of an already-freed value must also be safe. unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + unsafe { platform_wallet_parse_state_transition_free(ptr::null_mut()) }; } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex new file mode 100644 index 00000000000..6fb1a814b5f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex @@ -0,0 +1 @@ +070011111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222fb03e801000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex new file mode 100644 index 00000000000..a898d0e311b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex @@ -0,0 +1 @@ +000001e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f135010000000001010000010121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f7065727469657313000000000000000000000001000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex new file mode 100644 index 00000000000..9b7463a4f06 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex @@ -0,0 +1 @@ +01000201e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f135010000000001010000010121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f70657274696573130000000000000000000000000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex new file mode 100644 index 00000000000..3d8f3dd950f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex @@ -0,0 +1 @@ +06001111111111111111111111111111111111111111111111111111111111111111070902001100000200002102020202020202020202020202020202020202020202020202020202020202020241aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa011200000201026666666666666666666666666666666666666666666666666666666666666666002103030303030303030303030303030303030303030303030303030303030303030301fd00000002540be40001fd000001a3185c500041bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0204080203419999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex new file mode 100644 index 00000000000..8b7f67cdf10 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex @@ -0,0 +1 @@ +0201212121212121212121212121212121212121212121212121212121212121212104000000000d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0104706f73744242424242424242424242424242424242424242424242424242424242424242eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01076d6573736167651202686900000300000d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d010770726f66696c6542424242424242424242424242424242424242424242424242424242424242420222222222222222222222222222222222222222222222222222222222222222220102000004034242424242424242424242424242424242424242424242424242424242424242777777777777777777777777777777777777777777777777777777777777777700fa2222222222222222222222222222222222222222222222222222222222222222000000010900000403424242424242424242424242424242424242424242424242424242424242424277777777777777777777777777777777777777777777777777777777777777770064fc05f5e100010200 From 749486f3701ec56a390b5df0a18cc14aabcd967d Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 19:00:21 -0500 Subject: [PATCH 4/4] feat(sdk)!: DashPay Connect key derivation and decode-any-kind parsing in Swift and Kotlin Swift: ManagedPlatformWallet.deriveConnectKey(subFeature:identityId:leaf:purpose:network:storage:) over dash_sdk_derive_connect_key_with_resolver, with ConnectSubFeature (6 session authentication, 7 app encryption) and ConnectKeyPurpose (encryption, decryption; nil = no purpose level). parseStateTransition returns a ParsedStateTransition carrying kindName, ownerId, isSigned, the tagged serialized bytes and a ParsedStateTransitionKind (identityUpdate, batch, creditTransfer, dataContractCreate, dataContractUpdate, other); parsed IdentityPubkey rows surface totalBudget / expiresAt. Both the new FFI call and the pre-existing deriveIdentityAuthKeyAtSlot now pin the MnemonicResolver with withExtendedLifetime across the synchronous call. Kotlin: PlatformWalletManager.deriveConnectKey over the new JNI export IdentityNative.deriveConnectKeyWithResolver; StateTransitionParser.parse over TransactionsNative.parseStateTransition, whose JNI side packs ParsedStateTransitionFFI into a big-endian blob (layout in rs-unified-sdk-jni/src/parse_state_transition.rs). The blobs of two fixtures are checked in as goldens that both the JNI tests (include_bytes!) and the host-JVM StateTransitionParserTest read. BREAKING CHANGE: Swift ManagedPlatformWallet.ParsedTokenPurchaseTransition and the two-case ParsedStateTransition enum are removed; parseStateTransition returns the new struct. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + .../dashsdk/ffi/IdentityNative.kt | 25 + .../dashsdk/ffi/TransactionsNative.kt | 11 + .../dashsdk/identity/StateTransitionParser.kt | 334 +++++++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 65 +++ .../identity/StateTransitionParserTest.kt | 115 ++++ .../golden/parsed_identity_update_v1.bin | Bin 0 -> 329 bytes .../golden/parsed_token_transfer_batch_v1.bin | Bin 0 -> 372 bytes packages/rs-unified-sdk-jni/Cargo.toml | 3 + packages/rs-unified-sdk-jni/src/identity.rs | 90 +++ packages/rs-unified-sdk-jni/src/lib.rs | 1 + .../src/parse_state_transition.rs | 525 +++++++++++++++++ .../ManagedPlatformWallet.swift | 529 +++++++++++++++--- .../ConnectKeyDerivationTests.swift | 141 +++++ .../ParseStateTransitionTests.swift | 182 ++++++ 15 files changed, 1941 insertions(+), 81 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin create mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin create mode 100644 packages/rs-unified-sdk-jni/src/parse_state_transition.rs create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift diff --git a/Cargo.lock b/Cargo.lock index 42621c52582..5ae8cc96d2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6512,6 +6512,7 @@ dependencies = [ "dash-network", "dashcore", "dashpay-contract", + "dpp", "jni 0.21.1", "key-wallet-ffi", "log", diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt index 29be7860bdf..cdef46fe9fe 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt @@ -153,6 +153,31 @@ internal object IdentityNative { keyIndex: Int, ): Array + /** + * DashPay Connect key derivation at the DIP-13 sub-feature paths + * `m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']` + * (dashpay/dips#191). Bridges `dash_sdk_derive_connect_key_with_resolver`, + * a pure resolver-keyed derive like the slot derives above. + * + * @param subFeature 6 (session authentication; [leaf] is the request + * id) or 7 (app encryption; [leaf] is the bound contract's id). + * @param identityId the identity's 32-byte id (a DIP-14 hardened child). + * @param leaf the 32-byte leaf (a DIP-14 hardened child). + * @param purpose 0 for no purpose level (session authentication), or + * the half of an encryption pair as one more hardened child: 1 + * ENCRYPTION, 2 DECRYPTION. Anything else throws. + * @return `[privateKey(32), publicKey(33)]`. + */ + external fun deriveConnectKeyWithResolver( + networkOrd: Int, + walletId: ByteArray, + resolverHandle: Long, + subFeature: Int, + identityId: ByteArray, + leaf: ByteArray, + purpose: Int, + ): Array + /** * Register a new identity funded from the wallet's Core balance. The * single FFI entry point the registration coordinator's body invokes. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 7c3b6e6c6a7..dca16a452e6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -17,6 +17,17 @@ package org.dashfoundation.dashsdk.ffi */ internal object TransactionsNative { + /** + * Decode any DPP state transition (a `dash-st:` payload or a DashPay + * Connect `sign` request) into the packed big-endian blob documented + * in `rs-unified-sdk-jni/src/parse_state_transition.rs`, decoded by + * [org.dashfoundation.dashsdk.identity.StateTransitionParser.parseBlob]. + * Bridges `platform_wallet_parse_state_transition`. Accepts tagged and + * Yappr's tagless framing; never signs or broadcasts. Throws + * [DashSDKException] on undecodable bytes. + */ + external fun parseStateTransition(transitionBytes: ByteArray): ByteArray + /** * Add public keys and/or disable existing key ids on an identity, * signing the resulting `IdentityUpdateTransition` via [signerHandle] diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt new file mode 100644 index 00000000000..cb4d9449ce7 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt @@ -0,0 +1,334 @@ +package org.dashfoundation.dashsdk.identity + +import java.nio.ByteBuffer +import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.TransactionsNative + +/** + * One transition inside a parsed `BatchTransition`, in batch order — the + * Kotlin mirror of Swift's `ManagedPlatformWallet.ParsedBatchedTransition`. + * + * [action] is the rs-dpp action name (`Create`, `Replace`, `Delete`, + * `Transfer`, `Purchase`, `UpdatePrice`, `IndexOnlyDelete` for documents; + * `Burn`, `Mint`, `Transfer`, `Freeze`, `Unfreeze`, `DestroyFrozenFunds`, + * `Claim`, `EmergencyAction`, `ConfigUpdate`, `DirectPurchase`, + * `SetPriceForDirectPurchase` for tokens). + */ +sealed class ParsedBatchedTransition { + abstract val dataContractId: ByteArray + abstract val action: String + /** Credits or tokens the transition moves, when it moves any. */ + abstract val amount: Long? + /** + * The identity on the other side of the transition, when there is one. + * Interpret it against [action]: a transfer's recipient, a mint's + * issued-to identity, or the identity whose tokens a freeze / unfreeze / + * destroy acts on. + */ + abstract val recipientId: ByteArray? + + /** + * A document transition. [amount] is a purchase price or an + * update-price value; [recipientId] is a transfer's new owner. + */ + class Document( + override val dataContractId: ByteArray, + val documentType: String, + val documentId: ByteArray, + override val action: String, + override val amount: Long?, + override val recipientId: ByteArray?, + ) : ParsedBatchedTransition() { + override fun equals(other: Any?): Boolean = + other is Document && + dataContractId.contentEquals(other.dataContractId) && + documentType == other.documentType && + documentId.contentEquals(other.documentId) && + action == other.action && + amount == other.amount && + recipientId.contentEqualsNullable(other.recipientId) + + override fun hashCode(): Int = + listOf(dataContractId.contentHashCode(), documentType, documentId.contentHashCode(), action) + .hashCode() + + override fun toString(): String = + "Document(action=$action, documentType=$documentType, amount=$amount)" + } + + /** + * A token transition. [amount] is the transferred / minted / burned + * token count or a direct purchase's total agreed price; [recipientId] + * is a transfer's recipient, a mint's issued-to identity, or the frozen + * identity of a freeze / unfreeze / destroy. + */ + class Token( + override val dataContractId: ByteArray, + val tokenId: ByteArray, + val tokenContractPosition: Int, + override val action: String, + override val amount: Long?, + override val recipientId: ByteArray?, + ) : ParsedBatchedTransition() { + override fun equals(other: Any?): Boolean = + other is Token && + dataContractId.contentEquals(other.dataContractId) && + tokenId.contentEquals(other.tokenId) && + tokenContractPosition == other.tokenContractPosition && + action == other.action && + amount == other.amount && + recipientId.contentEqualsNullable(other.recipientId) + + override fun hashCode(): Int = + listOf(dataContractId.contentHashCode(), tokenId.contentHashCode(), tokenContractPosition, action) + .hashCode() + + override fun toString(): String = + "Token(action=$action, position=$tokenContractPosition, amount=$amount)" + } +} + +/** The typed summary of a parsed state transition, discriminated by kind. */ +sealed class ParsedStateTransitionKind { + /** Key registration / revocation (`IdentityUpdateTransition`). */ + class IdentityUpdate( + val identityId: ByteArray, + val addPublicKeys: List, + val disablePublicKeyIds: List, + ) : ParsedStateTransitionKind() + + /** Document and token operations (`BatchTransition`). */ + class Batch( + val ownerId: ByteArray, + val transitions: List, + ) : ParsedStateTransitionKind() + + /** `IdentityCreditTransferTransition`. */ + class CreditTransfer( + val identityId: ByteArray, + val recipientId: ByteArray, + val amount: Long, + ) : ParsedStateTransitionKind() + + /** `DataContractCreateTransition`. */ + class DataContractCreate(val contract: ParsedDataContract) : ParsedStateTransitionKind() + + /** `DataContractUpdateTransition`. */ + class DataContractUpdate(val contract: ParsedDataContract) : ParsedStateTransitionKind() + + /** Any other kind; see [ParsedStateTransition.kindName]. */ + object Other : ParsedStateTransitionKind() +} + +/** Inspectable fields of a parsed data contract create / update. */ +class ParsedDataContract( + val contractId: ByteArray, + val ownerId: ByteArray, + /** Document type names the contract defines, as the contract orders them. */ + val documentTypeNames: List, +) + +/** + * One parsed state transition (a `dash-st:` payload or a DashPay Connect + * `sign` request), decoded whatever its kind — the Kotlin mirror of Swift's + * `ManagedPlatformWallet.ParsedStateTransition`. + * + * [serialized] holds the bytes that actually decoded, always in tagged DPP + * framing (the variant tag is prepended when the input arrived tagless); + * after approval, sign these rather than the input so what was shown is + * what is signed. A `sign` request must arrive with `isSigned == false` + * and [ownerId] equal to the wallet's own identity; both checks are the + * caller's. + */ +class ParsedStateTransition( + /** rs-dpp `StateTransition::name()`, e.g. `IdentityUpdate`, `MasternodeVote`. */ + val kindName: String, + /** The identity the transition acts for; null for kinds that name none. */ + val ownerId: ByteArray?, + /** Whether the transition already carries a signature. */ + val isSigned: Boolean, + /** The decoded bytes, tagged. */ + val serialized: ByteArray, + val kind: ParsedStateTransitionKind, +) + +/** + * Decode-any-kind state transition parser — the Android analog of Swift's + * `ManagedPlatformWallet.parseStateTransition`. One FFI call + * ([TransactionsNative.parseStateTransition]) returns a packed blob that + * [parseBlob] turns into a [ParsedStateTransition]; the blob layout is + * documented in `rs-unified-sdk-jni/src/parse_state_transition.rs`. + */ +object StateTransitionParser { + + /** `ParsedStateTransitionFFI::kind` values (`PARSED_STATE_TRANSITION_KIND_*`). */ + private const val KIND_IDENTITY_UPDATE = 1 + private const val KIND_BATCH = 2 + private const val KIND_CREDIT_TRANSFER = 3 + private const val KIND_DATA_CONTRACT_CREATE = 4 + private const val KIND_DATA_CONTRACT_UPDATE = 5 + private const val KIND_OTHER = 255 + + private const val FAMILY_DOCUMENT = 0 + private const val FAMILY_TOKEN = 1 + + /** + * Decode [transitionBytes] (tagged or Yappr's tagless framing). Never + * signs or broadcasts. Throws the mapped native error on undecodable + * bytes. + */ + fun parse(transitionBytes: ByteArray): ParsedStateTransition { + require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" } + val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) } + return try { + parseBlob(blob) + } catch (e: RuntimeException) { + // A blob the JNI layer produced but this decoder cannot read is + // a Rust/Kotlin layout drift, not caller input; surface it as + // the same exception type native failures use so callers have + // one error path. + throw DashSDKException( + BLOB_DECODE_ERROR_CODE, + "parsed state transition blob could not be decoded: ${e.message}", + ) + } + } + + /** Mirrors the `99` internal-marshalling code the JNI layer throws with. */ + private const val BLOB_DECODE_ERROR_CODE = 99 + + /** + * Decode the packed blob the JNI layer returns. Internal + pure so it is + * host-JVM unit-testable without the native library + * (`StateTransitionParserTest` decodes the goldens the Rust side pins). + */ + internal fun parseBlob(blob: ByteArray): ParsedStateTransition { + val buf = ByteBuffer.wrap(blob) // big-endian by default + val kindTag = buf.get().toInt() and 0xFF + val kindName = readString16(buf) + val ownerId = if (readBool(buf)) readId32(buf) else null + val isSigned = readBool(buf) + val serialized = readBytes32Len(buf) + + val kind: ParsedStateTransitionKind = when (kindTag) { + KIND_IDENTITY_UPDATE -> { + val identityId = readId32(buf) + val added = List(readCount(buf, "added keys")) { readPublicKey(buf) } + val disabled = List(readCount(buf, "disabled keys")) { buf.int } + ParsedStateTransitionKind.IdentityUpdate(identityId, added, disabled) + } + KIND_BATCH -> { + val batchOwner = readId32(buf) + val transitions = List(readCount(buf, "batched transitions")) { readBatched(buf) } + ParsedStateTransitionKind.Batch(batchOwner, transitions) + } + KIND_CREDIT_TRANSFER -> + ParsedStateTransitionKind.CreditTransfer(readId32(buf), readId32(buf), buf.long) + KIND_DATA_CONTRACT_CREATE -> + ParsedStateTransitionKind.DataContractCreate(readDataContract(buf)) + KIND_DATA_CONTRACT_UPDATE -> + ParsedStateTransitionKind.DataContractUpdate(readDataContract(buf)) + KIND_OTHER -> ParsedStateTransitionKind.Other + else -> throw IllegalArgumentException("malformed parse blob: unknown kind $kindTag") + } + + require(!buf.hasRemaining()) { "malformed parse blob: trailing bytes" } + return ParsedStateTransition(kindName, ownerId, isSigned, serialized, kind) + } + + private fun readPublicKey(buf: ByteBuffer): IdentityPubkey { + val keyId = buf.int + val keyType = buf.get().toInt() and 0xFF + val purpose = buf.get().toInt() and 0xFF + val securityLevel = buf.get().toInt() and 0xFF + val readOnly = readBool(buf) + val boundsKind = buf.get().toInt() and 0xFF + val dataLen = buf.short.toInt() and 0xFFFF + val data = ByteArray(dataLen).also { buf.get(it) } + val boundsId = if (boundsKind != 0) readId32(buf) else null + val docType = if (boundsKind == 2) readString16(buf) else null + val flags = buf.get().toInt() and 0xFF + val totalBudget = if (flags and 1 != 0) buf.long else null + val expiresAt = if (flags and 2 != 0) buf.long else null + + val bounds: ContractBounds? = when (boundsKind) { + 0 -> null + 1 -> ContractBounds.SingleContract(boundsId!!) + 2 -> ContractBounds.SingleContractDocumentType(boundsId!!, docType!!) + 3 -> ContractBounds.ContractGroup(boundsId!!) + else -> throw IllegalArgumentException("malformed parse blob: contract bounds kind $boundsKind") + } + return IdentityPubkey( + keyId = keyId, + keyType = KeyType.entries.firstOrNull { it.ffiValue == keyType } + ?: throw IllegalArgumentException("malformed parse blob: key type $keyType"), + purpose = KeyPurpose.entries.firstOrNull { it.ffiValue == purpose } + ?: throw IllegalArgumentException("malformed parse blob: purpose $purpose"), + securityLevel = SecurityLevel.entries.firstOrNull { it.ffiValue == securityLevel } + ?: throw IllegalArgumentException("malformed parse blob: security level $securityLevel"), + pubkeyBytes = data, + readOnly = readOnly, + contractBounds = bounds, + totalBudget = totalBudget, + expiresAt = expiresAt, + ) + } + + private fun readBatched(buf: ByteBuffer): ParsedBatchedTransition { + val family = buf.get().toInt() and 0xFF + val dataContractId = readId32(buf) + val action = readString16(buf) + return when (family) { + FAMILY_DOCUMENT -> { + val documentType = readString16(buf) + val documentId = readId32(buf) + val amount = if (readBool(buf)) buf.long else null + val recipient = if (readBool(buf)) readId32(buf) else null + ParsedBatchedTransition.Document(dataContractId, documentType, documentId, action, amount, recipient) + } + FAMILY_TOKEN -> { + val position = buf.short.toInt() and 0xFFFF + val tokenId = readId32(buf) + val amount = if (readBool(buf)) buf.long else null + val recipient = if (readBool(buf)) readId32(buf) else null + ParsedBatchedTransition.Token(dataContractId, tokenId, position, action, amount, recipient) + } + else -> throw IllegalArgumentException("malformed parse blob: batched family $family") + } + } + + private fun readDataContract(buf: ByteBuffer): ParsedDataContract { + val contractId = readId32(buf) + val ownerId = readId32(buf) + val names = List(readCount(buf, "document type names")) { readString16(buf) } + return ParsedDataContract(contractId, ownerId, names) + } + + private fun readBool(buf: ByteBuffer): Boolean = buf.get().toInt() != 0 + + private fun readId32(buf: ByteBuffer): ByteArray = ByteArray(32).also { buf.get(it) } + + private fun readCount(buf: ByteBuffer, what: String): Int { + val count = buf.int + require(count >= 0) { "malformed parse blob: negative $what count" } + return count + } + + /** `u16 len + UTF-8 bytes`. */ + private fun readString16(buf: ByteBuffer): String { + val len = buf.short.toInt() and 0xFFFF + val bytes = ByteArray(len).also { buf.get(it) } + return String(bytes, Charsets.UTF_8) + } + + /** `u32 len + bytes`. */ + private fun readBytes32Len(buf: ByteBuffer): ByteArray { + val len = buf.int + require(len >= 0) { "malformed parse blob: negative length" } + return ByteArray(len).also { buf.get(it) } + } +} + +private fun ByteArray?.contentEqualsNullable(other: ByteArray?): Boolean = + if (this == null || other == null) this === other else contentEquals(other) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index bce8324ef49..a18ed364506 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -640,6 +640,71 @@ class PlatformWalletManager( pair[0] to pair[1] } + /** + * DIP-13 sub-features DashPay Connect v2 keys live under + * (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`), + * registered by the DIP-13 amendment dashpay/dips#191. Mirror of + * Swift's `ManagedPlatformWallet.ConnectSubFeature`. + */ + enum class ConnectSubFeature(val value: Int) { + /** Session authentication key: the leaf is the connect request id; no purpose level. */ + SESSION_AUTHENTICATION(6), + /** + * App encryption key pair: the leaf is the bound data contract's id; + * the purpose level is a [ConnectKeyPurpose]. + */ + APP_ENCRYPTION(7), + } + + /** + * The trailing `purpose'` level of the app-encryption path: the DPP + * purpose discriminant of the half being derived. Only these two exist + * in the DIP-13 amendment, so the type makes any other value (in + * particular 0, the FFI's "no purpose level") unrepresentable. Mirror + * of Swift's `ManagedPlatformWallet.ConnectKeyPurpose`. + */ + enum class ConnectKeyPurpose(val value: Int) { + ENCRYPTION(1), + DECRYPTION(2), + } + + /** + * Derive a DashPay Connect key at + * `m/9'/coin'/5'/'/0'/'/'[/']` + * from the wallet's seed, resolved through the mnemonic resolver — the + * Android analog of Swift's `deriveConnectKey(subFeature:identityId:leaf:purpose:)`. + * [identityId] and [leaf] are DIP-14 256-bit hardened children, so no + * wallet-local counter is an input; [purpose], when given, appends one + * more hardened child (the encryption sub-feature splits its pair with + * [ConnectKeyPurpose.ENCRYPTION] / [ConnectKeyPurpose.DECRYPTION]; the + * authentication sub-feature passes null, meaning no purpose level). + * + * @return `(privateKey(32), publicKey(33))`. Caller zeroes the private half. + */ + suspend fun deriveConnectKey( + walletId: ByteArray, + subFeature: ConnectSubFeature, + identityId: ByteArray, + leaf: ByteArray, + purpose: ConnectKeyPurpose? = null, + ): Pair = teardownGate.op { + require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" } + require(leaf.size == 32) { "leaf must be 32 bytes, got ${leaf.size}" } + val pair = org.dashfoundation.dashsdk.errors.mapNativeErrors { + org.dashfoundation.dashsdk.ffi.IdentityNative.deriveConnectKeyWithResolver( + network.ffiValue, + walletId, + mnemonicResolver.nativeHandle, + subFeature.value, + identityId, + leaf, + purpose?.value ?: 0, + ) + } + check(pair.size == 2) { "connect key derive returned ${pair.size} elements" } + pair[0] to pair[1] + } + /** * Identity registration / discovery / DPNS-name bridge. Stateless * wrapper over the identity JNI surface; callers thread the wallet diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt new file mode 100644 index 00000000000..43e72161677 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt @@ -0,0 +1,115 @@ +package org.dashfoundation.dashsdk.identity + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host-JVM tests of [StateTransitionParser.parseBlob] — the Kotlin half of + * the JNI decode contract. The golden blobs under `resources/golden/` are + * the EXACT bytes the Rust side produces and pins in + * `rs-unified-sdk-jni/src/parse_state_transition.rs` + * (`*_blob_round_trips_and_is_pinned_for_kotlin`, which `include_bytes!` + * these same files), so the layout is verified from both ends without + * loading the native library. + */ +class StateTransitionParserTest { + + private fun golden(name: String): ByteArray = + javaClass.getResourceAsStream("/golden/$name") + .use { requireNotNull(it) { "golden fixture $name missing" }.readBytes() } + + @Test + fun `decodes an identity update registering a limited group-bound session key`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_identity_update_v1.bin")) + + assertEquals("IdentityUpdate", parsed.kindName) + assertArrayEquals(ByteArray(32) { 0x11 }, parsed.ownerId) + assertFalse(parsed.isSigned) + assertEquals("IdentityUpdate variant tag", 6, parsed.serialized.first().toInt()) + + val update = parsed.kind as ParsedStateTransitionKind.IdentityUpdate + assertArrayEquals(ByteArray(32) { 0x11 }, update.identityId) + assertEquals(listOf(4), update.disablePublicKeyIds) + assertEquals(1, update.addPublicKeys.size) + + val session = update.addPublicKeys.single() + assertEquals(18, session.keyId) + assertEquals(KeyType.ECDSA_SECP256K1, session.keyType) + assertEquals(KeyPurpose.AUTHENTICATION, session.purpose) + assertEquals(SecurityLevel.HIGH, session.securityLevel) + assertFalse(session.readOnly) + assertArrayEquals(ByteArray(33) { 0x03 }, session.pubkeyBytes) + assertEquals(ContractBounds.ContractGroup(ByteArray(32) { 0x66 }), session.contractBounds) + assertEquals(10_000_000_000L, session.totalBudget) + assertEquals(1_800_000_000_000L, session.expiresAt) + assertTrue(session.hasLimits) + } + + @Test + fun `decodes a batch carrying a token transfer`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_token_transfer_batch_v1.bin")) + + assertEquals("DocumentsBatch([TokenTransfer])", parsed.kindName) + assertArrayEquals(ByteArray(32) { 0x21 }, parsed.ownerId) + assertFalse(parsed.isSigned) + assertEquals("Batch variant tag", 2, parsed.serialized.first().toInt()) + + val batch = parsed.kind as ParsedStateTransitionKind.Batch + assertArrayEquals(ByteArray(32) { 0x21 }, batch.ownerId) + assertEquals( + listOf( + ParsedBatchedTransition.Token( + dataContractId = ByteArray(32) { 0x42 }, + tokenId = ByteArray(32) { 0x77 }, + tokenContractPosition = 3, + action = "Transfer", + amount = 250L, + recipientId = ByteArray(32) { 0x22 }, + ), + ), + batch.transitions, + ) + } + + @Test + fun `decodes the other kind with only the common fields`() { + // kind 255, name "MasternodeVote", no owner, signed, empty serialized. + val name = "MasternodeVote".toByteArray() + val blob = byteArrayOf(0xFF.toByte(), 0, name.size.toByte()) + name + + byteArrayOf(0, 1, 0, 0, 0, 0) + val parsed = StateTransitionParser.parseBlob(blob) + + assertEquals("MasternodeVote", parsed.kindName) + assertNull(parsed.ownerId) + assertTrue(parsed.isSigned) + assertEquals(0, parsed.serialized.size) + assertTrue(parsed.kind is ParsedStateTransitionKind.Other) + } + + @Test + fun `truncated and trailing-garbage blobs throw`() { + val golden = golden("parsed_token_transfer_batch_v1.bin") + assertThrows(RuntimeException::class.java) { + StateTransitionParser.parseBlob(golden.copyOf(golden.size - 5)) + } + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(golden + byteArrayOf(0)) + } + assertTrue(error.message!!.contains("trailing")) + } + + @Test + fun `unknown kind tag throws rather than decoding as other`() { + val name = "Whatever".toByteArray() + val blob = byteArrayOf(77, 0, name.size.toByte()) + name + byteArrayOf(0, 0, 0, 0, 0, 0) + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(blob) + } + assertTrue(error.message!!.contains("unknown kind")) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..4f41448de1e29737ed5d098bff3969280513c4c9 GIT binary patch literal 329 zcmZQ%;PXsL%`3?)sSGVhNi0ca6eIu`fS{9&fq*JOc1}h{AqEB}My50ZfI*R&5McZZ ww3I1?`w0Vx#kg1^CICpW00jv-1E_=%h(Ru4U?##Q0&Y!XhB*Z842T6R0Lkbs*#H0l literal 0 HcmV?d00001 diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..8b2f4a77217edf671477573a11a527255433d4fc GIT binary patch literal 372 zcmZQ#kax*XF3nBND=BtLEJ@DLhz`lmPR$D`O3W)xOD&4kWK<*o7=U0R6G3H+j7$s+ zEX+;>Ksf=x@JopR0J(>OfqumVx0HBLVj*0RUCr BRSf_D literal 0 HcmV?d00001 diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..e4a7fcad504 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -28,6 +28,9 @@ dashcore = { workspace = true } # Anchors the cross-language golden-fixture test to the canonical DashPay # contract id, so the mirrored Kotlin constant can't drift undetected. dashpay-contract = { path = "../dashpay-contract" } +# Builds the state-transition fixtures whose parsed blobs are the goldens +# shared with the Kotlin `StateTransitionParserTest`. +dpp = { path = "../rs-dpp" } [features] default = ["shielded"] diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 48418e3d66c..09e734eb36a 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -37,6 +37,7 @@ use jni::objects::{JByteArray, JClass, JString, JValue}; use jni::sys::{jboolean, jbyteArray, jint, jlong, jobject}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; +use platform_wallet_ffi::derive_connect_key::ConnectDerivedKeyFFI; use platform_wallet_ffi::error::platform_wallet_ffi_result_free; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_discovery::DiscoveredIdentityIdsFFI; @@ -482,6 +483,95 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive }) } +/// DashPay Connect key derivation at the DIP-13 sub-feature paths +/// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`, +/// dashpay/dips#191). Thin marshaler over +/// `dash_sdk_derive_connect_key_with_resolver`: resolver-keyed and pure +/// like its slot-derive siblings, so it is safe from persistence-callback +/// context. Returns `[privateKey: byte[32], publicKey: byte[33]]`; the +/// Rust-side key is zeroized before return and Kotlin owns the copy. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_deriveConnectKeyWithResolver( + mut env: JNIEnv, + _class: JClass, + network_ord: jint, + wallet_id: JByteArray, + resolver_handle: jlong, + sub_feature: jint, + identity_id: JByteArray, + leaf: JByteArray, + purpose: jint, +) -> jni::sys::jobjectArray { + guard(&mut env, ptr::null_mut(), |env| { + if sub_feature < 0 { + throw_sdk_exception(env, 1, "subFeature must be non-negative"); + return ptr::null_mut(); + } + if !(0..=2).contains(&purpose) { + throw_sdk_exception( + env, + 1, + "purpose must be 0 (none), 1 (ENCRYPTION) or 2 (DECRYPTION)", + ); + return ptr::null_mut(); + } + if resolver_handle == 0 { + throw_sdk_exception(env, 1, "resolverHandle must be non-zero"); + return ptr::null_mut(); + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(identity) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let Some(leaf) = read_id32(env, &leaf, "leaf") else { + return ptr::null_mut(); + }; + + let mut out = ConnectDerivedKeyFFI::empty(); + let result = unsafe { + platform_wallet_ffi::dash_sdk_derive_connect_key_with_resolver( + net_from_ord(network_ord), + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + sub_feature as u32, + identity.as_ptr(), + leaf.as_ptr(), + purpose as u32, + &mut out as *mut ConnectDerivedKeyFFI, + ) + }; + if take_pwffi_error(env, result) { + unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; + return ptr::null_mut(); + } + + // Copy both halves before the free wipes the inline buffers; the + // private copy is zeroized on drop. + let scalar = zeroize::Zeroizing::new(out.private_key_bytes); + let pubkey = out.public_key_bytes; + unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; + + let build = (|| -> Result { + let priv_arr = env.byte_array_from_slice(&*scalar)?; + let pub_arr = env.byte_array_from_slice(&pubkey)?; + let byte_array_class = env.find_class("[B")?; + let result = + env.new_object_array(2, byte_array_class, jni::objects::JObject::null())?; + env.set_object_array_element(&result, 0, priv_arr)?; + env.set_object_array_element(&result, 1, pub_arr)?; + Ok(result.into_raw()) + })(); + build.unwrap_or_else(|_| { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "connect key marshalling failed"); + ptr::null_mut() + }) + }) +} + // ── Registration (wallet-balance funded) ────────────────────────────── /// Resume a previously interrupted identity registration from a tracked diff --git a/packages/rs-unified-sdk-jni/src/lib.rs b/packages/rs-unified-sdk-jni/src/lib.rs index 3e9caf17b7b..e955750016a 100644 --- a/packages/rs-unified-sdk-jni/src/lib.rs +++ b/packages/rs-unified-sdk-jni/src/lib.rs @@ -21,6 +21,7 @@ mod events; mod funding; mod identity; mod mnemonic; +mod parse_state_transition; mod persistence; mod pubkey_rows; mod queries; diff --git a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs new file mode 100644 index 00000000000..a8a42d1f442 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs @@ -0,0 +1,525 @@ +//! JNI bridge for decode-any-kind state transition parsing — a thin +//! marshaler over `platform_wallet_parse_state_transition` (single Rust FFI +//! entry point, per the `packages/kotlin-sdk/CLAUDE.md` boundary rule). +//! +//! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.TransactionsNative +//! .parseStateTransition`, driven by +//! `org.dashfoundation.dashsdk.identity.StateTransitionParser` — the +//! Android analog of Swift's `ManagedPlatformWallet.parseStateTransition`. +//! +//! ## Result convention +//! +//! `ParsedStateTransitionFFI` is a pointer graph (owned C strings and +//! arrays per kind). Rather than exposing a native handle plus N accessors +//! across JNI, the whole result is copied ONCE into a packed big-endian +//! blob (the convention `tx_decode.rs` and `pubkey_rows.rs` use) and every +//! Rust allocation is released via `platform_wallet_parse_state_transition_free` +//! before the export returns. Errors throw `DashSDKException` through the +//! shared `take_pwffi_error` mapping. +//! +//! ## BLOB layout (big-endian; keep in sync with `StateTransitionParser.parseBlob`) +//! +//! ```text +//! u8 kind (PARSED_STATE_TRANSITION_KIND_*) +//! u16 kind_name_len, u8[kind_name_len] kind_name (UTF-8) +//! u8 has_owner_id; if 1: u8[32] owner_id +//! u8 is_signed +//! u32 serialized_len, u8[serialized_len] serialized (tagged DPP bytes) +//! kind 1 (IdentityUpdate): +//! u8[32] identity_id +//! u32 add_count; repeat add_count times (same field order as +//! `IdentityPubkeyCodec`): +//! u32 key_id, u8 key_type, u8 purpose, u8 security_level, u8 read_only, +//! u8 contract_bounds_kind, u16 data_len, u8[data_len] data, +//! if contract_bounds_kind != 0: u8[32] contract_bounds_id, +//! if contract_bounds_kind == 2: u16 doc_type_len, u8[doc_type_len] doc_type, +//! u8 limits_flags (bit 0: u64 total_budget follows, bit 1: u64 expires_at follows) +//! u32 disable_count, u32[disable_count] disable_ids +//! kind 2 (Batch): +//! u8[32] owner_id +//! u32 count; repeat count times: +//! u8 family (0 document, 1 token), u8[32] data_contract_id, +//! u16 action_len, u8[action_len] action, +//! family 0: u16 doc_type_len, u8[doc_type_len] doc_type, u8[32] document_id +//! family 1: u16 token_contract_position, u8[32] token_id +//! u8 has_amount; if 1: u64 amount +//! u8 has_recipient; if 1: u8[32] recipient_id +//! kind 3 (IdentityCreditTransfer): +//! u8[32] identity_id, u8[32] recipient_id, u64 amount +//! kind 4 / 5 (DataContractCreate / DataContractUpdate): +//! u8[32] contract_id, u8[32] owner_id, +//! u32 count; repeat count times: u16 len, u8[len] document_type_name +//! kind 255 (other): nothing further +//! ``` + +use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; +use jni::objects::{JByteArray, JClass}; +use jni::sys::jbyteArray; +use jni::JNIEnv; +use platform_wallet_ffi::identity_update::ParsedIdentityUpdatePublicKeyFFI; +use platform_wallet_ffi::parse_state_transition::{ + platform_wallet_parse_state_transition, platform_wallet_parse_state_transition_free, + ParsedBatchedTransitionFFI, ParsedStateTransitionFFI, + PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT, PARSED_STATE_TRANSITION_KIND_BATCH, + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE, + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE, +}; +use std::ffi::CStr; +use std::os::raw::c_char; +use std::ptr; + +/// Append `u16 len + bytes` for a required C string. A null pointer or an +/// over-long string is a bug in the producing FFI, so it is reported as an +/// error rather than silently encoded as empty. +/// +/// # Safety +/// `ptr` must be null or a valid NUL-terminated C string. +unsafe fn push_cstr(blob: &mut Vec, ptr: *const c_char, what: &str) -> Result<(), String> { + if ptr.is_null() { + return Err(format!("{what} is null")); + } + let bytes = CStr::from_ptr(ptr).to_bytes(); + let len = u16::try_from(bytes.len()).map_err(|_| format!("{what} exceeds u16 length"))?; + blob.extend_from_slice(&len.to_be_bytes()); + blob.extend_from_slice(bytes); + Ok(()) +} + +/// # Safety +/// `key` must be a row owned by a live `ParsedIdentityUpdateFFI`. +unsafe fn push_parsed_public_key( + blob: &mut Vec, + key: &ParsedIdentityUpdatePublicKeyFFI, +) -> Result<(), String> { + blob.extend_from_slice(&key.key_id.to_be_bytes()); + blob.push(key.key_type); + blob.push(key.purpose); + blob.push(key.security_level); + blob.push(u8::from(key.read_only)); + blob.push(key.contract_bounds_kind); + let data_len = u16::try_from(key.data_len).map_err(|_| "key data exceeds u16 length")?; + blob.extend_from_slice(&data_len.to_be_bytes()); + if key.data_len > 0 { + if key.data_ptr.is_null() { + return Err("key data pointer is null".to_string()); + } + blob.extend_from_slice(std::slice::from_raw_parts(key.data_ptr, key.data_len)); + } + if key.contract_bounds_kind != 0 { + blob.extend_from_slice(&key.contract_bounds_id); + } + if key.contract_bounds_kind == 2 { + push_cstr( + blob, + key.contract_bounds_document_type, + "contract bounds document type", + )?; + } + let flags = u8::from(key.has_total_budget) | (u8::from(key.has_expires_at) << 1); + blob.push(flags); + if key.has_total_budget { + blob.extend_from_slice(&key.total_budget.to_be_bytes()); + } + if key.has_expires_at { + blob.extend_from_slice(&key.expires_at.to_be_bytes()); + } + Ok(()) +} + +/// # Safety +/// `row` must be a row owned by a live `ParsedBatchFFI`. +unsafe fn push_batched_transition( + blob: &mut Vec, + row: &ParsedBatchedTransitionFFI, +) -> Result<(), String> { + blob.push(row.family); + blob.extend_from_slice(&row.data_contract_id); + push_cstr(blob, row.action, "batched transition action")?; + if row.family == PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT { + push_cstr(blob, row.document_type, "batched document type")?; + blob.extend_from_slice(&row.document_id); + } else { + blob.extend_from_slice(&row.token_contract_position.to_be_bytes()); + blob.extend_from_slice(&row.token_id); + } + blob.push(u8::from(row.has_amount)); + if row.has_amount { + blob.extend_from_slice(&row.amount.to_be_bytes()); + } + blob.push(u8::from(row.has_recipient)); + if row.has_recipient { + blob.extend_from_slice(&row.recipient_id); + } + Ok(()) +} + +fn push_count(blob: &mut Vec, count: usize, what: &str) -> Result<(), String> { + let count = u32::try_from(count).map_err(|_| format!("{what} count exceeds u32"))?; + blob.extend_from_slice(&count.to_be_bytes()); + Ok(()) +} + +/// Copy a `ParsedStateTransitionFFI` pointer graph into the packed blob. +/// +/// # Safety +/// `parsed` must be a successful, not yet freed result of +/// `platform_wallet_parse_state_transition`. +pub(crate) unsafe fn encode_parsed_state_transition( + parsed: &ParsedStateTransitionFFI, +) -> Result, String> { + let mut blob = Vec::with_capacity(256 + parsed.serialized_len); + blob.push(parsed.kind); + push_cstr(&mut blob, parsed.kind_name, "kind name")?; + blob.push(u8::from(parsed.has_owner_id)); + if parsed.has_owner_id { + blob.extend_from_slice(&parsed.owner_id); + } + blob.push(u8::from(parsed.is_signed)); + push_count(&mut blob, parsed.serialized_len, "serialized")?; + if parsed.serialized_len > 0 { + if parsed.serialized.is_null() { + return Err("serialized pointer is null".to_string()); + } + blob.extend_from_slice(std::slice::from_raw_parts( + parsed.serialized, + parsed.serialized_len, + )); + } + + match parsed.kind { + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE => { + let update = &parsed.identity_update; + blob.extend_from_slice(&update.identity_id); + push_count(&mut blob, update.add_public_keys_count, "added keys")?; + if update.add_public_keys_count > 0 { + if update.add_public_keys.is_null() { + return Err("added keys pointer is null".to_string()); + } + for key in + std::slice::from_raw_parts(update.add_public_keys, update.add_public_keys_count) + { + push_parsed_public_key(&mut blob, key)?; + } + } + push_count( + &mut blob, + update.disable_public_key_ids_count, + "disabled keys", + )?; + if update.disable_public_key_ids_count > 0 { + if update.disable_public_key_ids.is_null() { + return Err("disabled key ids pointer is null".to_string()); + } + for id in std::slice::from_raw_parts( + update.disable_public_key_ids, + update.disable_public_key_ids_count, + ) { + blob.extend_from_slice(&id.to_be_bytes()); + } + } + } + PARSED_STATE_TRANSITION_KIND_BATCH => { + let batch = &parsed.batch; + blob.extend_from_slice(&batch.owner_id); + push_count(&mut blob, batch.transitions_count, "batched transitions")?; + if batch.transitions_count > 0 { + if batch.transitions.is_null() { + return Err("batched transitions pointer is null".to_string()); + } + for row in std::slice::from_raw_parts(batch.transitions, batch.transitions_count) { + push_batched_transition(&mut blob, row)?; + } + } + } + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER => { + let transfer = &parsed.credit_transfer; + blob.extend_from_slice(&transfer.identity_id); + blob.extend_from_slice(&transfer.recipient_id); + blob.extend_from_slice(&transfer.amount.to_be_bytes()); + } + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + | PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE => { + let contract = &parsed.data_contract; + blob.extend_from_slice(&contract.contract_id); + blob.extend_from_slice(&contract.owner_id); + push_count( + &mut blob, + contract.document_type_names_count, + "document type names", + )?; + if contract.document_type_names_count > 0 { + if contract.document_type_names.is_null() { + return Err("document type names pointer is null".to_string()); + } + for name in std::slice::from_raw_parts( + contract.document_type_names, + contract.document_type_names_count, + ) { + push_cstr(&mut blob, *name, "document type name")?; + } + } + } + _ => {} + } + Ok(blob) +} + +/// Decode `bytes` as any DPP state transition and return the packed blob +/// described in the module doc. Throws `DashSDKException` on +/// undecodable bytes. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_parseStateTransition( + mut env: JNIEnv, + _class: JClass, + transition_bytes: JByteArray, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let bytes = match env.convert_byte_array(&transition_bytes) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "transitionBytes byte[] was null/invalid"); + return ptr::null_mut(); + } + }; + if bytes.is_empty() { + throw_sdk_exception(env, 1, "transitionBytes must not be empty"); + return ptr::null_mut(); + } + + let mut out = ParsedStateTransitionFFI::default(); + let result = unsafe { + platform_wallet_parse_state_transition(bytes.as_ptr(), bytes.len(), &mut out) + }; + if take_pwffi_error(env, result) { + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + return ptr::null_mut(); + } + + let encoded = unsafe { encode_parsed_state_transition(&out) }; + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + + match encoded { + Ok(blob) => env + .byte_array_from_slice(&blob) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()), + Err(message) => { + throw_sdk_exception( + env, + 99, + &format!("parsed transition encode failed: {message}"), + ); + ptr::null_mut() + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::batched_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::{ + BatchedTransition, TokenTransition, + }; + use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use dpp::state_transition::batch_transition::{ + BatchTransition, BatchTransitionV1, TokenTransferTransition, + }; + use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; + use dpp::state_transition::StateTransition; + + /// A DashPay Connect session-key registration: one HIGH auth key bound + /// to a contract group with a budget and an expiry, one key disabled. + /// The same fixture `StateTransitionParserTest` decodes on the Kotlin + /// side from the pinned hex below. + fn identity_update_bytes() -> Vec { + StateTransition::IdentityUpdate( + IdentityUpdateTransitionV0 { + signature: BinaryData::new(vec![]), + signature_public_key_id: 0, + identity_id: Identifier::from([0x11; 32]), + revision: 7, + nonce: 9, + add_public_keys: vec![IdentityPublicKeyInCreationV1 { + id: 18, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + read_only: false, + data: BinaryData::new(vec![0x03; 33]), + signature: BinaryData::new(vec![]), + contract_bounds: Some(ContractBounds::ContractGroup { + id: Identifier::from([0x66; 32]), + }), + total_budget: Some(10_000_000_000), + expires_at: Some(1_800_000_000_000), + } + .into()], + disable_public_keys: vec![4], + user_fee_increase: 0, + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture identity update serializes") + } + + fn token_transfer_batch_bytes() -> Vec { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([0x21; 32]), + transitions: vec![BatchedTransition::Token(TokenTransition::Transfer( + TokenTransferTransition::V0(TokenTransferTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 4, + token_contract_position: 3, + data_contract_id: Identifier::from([0x42; 32]), + token_id: Identifier::from([0x77; 32]), + using_group_info: None, + }), + amount: 250, + recipient_id: Identifier::from([0x22; 32]), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + .serialize_to_bytes() + .expect("fixture batch serializes") + } + + fn parse_to_blob(bytes: &[u8]) -> Vec { + let mut out = ParsedStateTransitionFFI::default(); + let result = unsafe { + platform_wallet_parse_state_transition(bytes.as_ptr(), bytes.len(), &mut out) + }; + assert_eq!( + result.code, + platform_wallet_ffi::error::PlatformWalletFFIResultCode::Success + ); + let blob = unsafe { encode_parsed_state_transition(&out) }.expect("blob encodes"); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + blob + } + + struct Reader<'a>(&'a [u8]); + impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> &'a [u8] { + let (head, tail) = self.0.split_at(n); + self.0 = tail; + head + } + fn u8(&mut self) -> u8 { + self.take(1)[0] + } + fn u16(&mut self) -> u16 { + u16::from_be_bytes(self.take(2).try_into().unwrap()) + } + fn u32(&mut self) -> u32 { + u32::from_be_bytes(self.take(4).try_into().unwrap()) + } + fn u64(&mut self) -> u64 { + u64::from_be_bytes(self.take(8).try_into().unwrap()) + } + fn str16(&mut self) -> String { + let len = self.u16() as usize; + String::from_utf8(self.take(len).to_vec()).unwrap() + } + } + + #[test] + fn identity_update_blob_round_trips_and_is_pinned_for_kotlin() { + let bytes = identity_update_bytes(); + let blob = parse_to_blob(&bytes); + + let mut r = Reader(&blob); + assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE); + assert_eq!(r.str16(), "IdentityUpdate"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x11; 32]); + assert_eq!(r.u8(), 0, "unsigned"); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.take(32), &[0x11; 32]); + assert_eq!(r.u32(), 1); + assert_eq!(r.u32(), 18); + assert_eq!(r.u8(), 0); // key type + assert_eq!(r.u8(), 0); // purpose + assert_eq!(r.u8(), 2); // HIGH + assert_eq!(r.u8(), 0); // read only + assert_eq!(r.u8(), 3); // ContractGroup + assert_eq!(r.u16(), 33); + assert_eq!(r.take(33), &[0x03; 33]); + assert_eq!(r.take(32), &[0x66; 32]); + assert_eq!(r.u8(), 0b11); + assert_eq!(r.u64(), 10_000_000_000); + assert_eq!(r.u64(), 1_800_000_000_000); + assert_eq!(r.u32(), 1); + assert_eq!(r.u32(), 4); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, IDENTITY_UPDATE_GOLDEN, + "identity-update blob drifted from the golden shared with StateTransitionParserTest" + ); + } + + #[test] + fn token_transfer_batch_blob_round_trips_and_is_pinned_for_kotlin() { + let bytes = token_transfer_batch_bytes(); + let blob = parse_to_blob(&bytes); + + let mut r = Reader(&blob); + assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!(r.str16(), "DocumentsBatch([TokenTransfer])"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u8(), 0); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u32(), 1); + assert_eq!(r.u8(), 1); // token family + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "Transfer"); + assert_eq!(r.u16(), 3); + assert_eq!(r.take(32), &[0x77; 32]); + assert_eq!(r.u8(), 1); + assert_eq!(r.u64(), 250); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x22; 32]); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, TOKEN_TRANSFER_GOLDEN, + "token-transfer blob drifted from the golden shared with StateTransitionParserTest" + ); + } + + /// The checked-in golden blobs, shared byte-for-byte with the Kotlin + /// decoder test (`StateTransitionParserTest`). Referenced from the single + /// canonical copy in the Kotlin SDK's test resources so the two cannot + /// drift, the way `pubkey_rows.rs` anchors its registration golden. + const IDENTITY_UPDATE_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin" + )); + const TOKEN_TRANSFER_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin" + )); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 84dae9131a2..f743ea6caff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -164,7 +164,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// Rust avoids the prior pubkey-derivation pass inside /// `platform_wallet_register_identity_with_signer` that fails on /// watch-only wallets where Rust has no in-process xpriv loaded. - public struct IdentityPubkey: Sendable { + public struct IdentityPubkey: Sendable, Equatable { public let keyId: UInt32 public let keyType: KeyType public let purpose: KeyPurpose @@ -232,7 +232,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// Inspectable fields of a parsed raw `IdentityUpdateTransition`. /// The keys intentionally reuse `IdentityPubkey` so callers can /// validate and hand them back to `updateIdentity(...)` unchanged. - public struct ParsedIdentityUpdateTransition: Sendable { + public struct ParsedIdentityUpdateTransition: Sendable, Equatable { public let identityId: Identifier public let addPublicKeys: [IdentityPubkey] public let disablePublicKeyIds: [UInt32] @@ -248,49 +248,154 @@ public final class ManagedPlatformWallet: @unchecked Sendable { } } - /// Inspectable fields of a token direct purchase parsed out of a - /// raw `BatchTransition`. Carries everything `tokenPurchase(...)` - /// needs to rebuild the purchase after user approval, plus what - /// the user must see before approving. - public struct ParsedTokenPurchaseTransition: Sendable { - /// The identity whose credits pay for the purchase. - public let ownerId: Identifier - /// The data contract defining the token. - public let dataContractId: Identifier - /// The token being bought. - public let tokenId: Identifier - /// Position of the token within the contract. - public let tokenContractPosition: UInt16 - /// How many tokens the dApp asks to buy. - public let tokenCount: UInt64 - /// Credits the owner would agree to pay in total. - public let totalAgreedPrice: UInt64 - - public init( - ownerId: Identifier, + /// One transition inside a parsed `BatchTransition`, in batch order. + /// + /// `action` is the rs-dpp action name (`Create`, `Replace`, `Delete`, + /// `Transfer`, `Purchase`, `UpdatePrice`, `IndexOnlyDelete` for + /// documents; `Burn`, `Mint`, `Transfer`, `Freeze`, `Unfreeze`, + /// `DestroyFrozenFunds`, `Claim`, `EmergencyAction`, `ConfigUpdate`, + /// `DirectPurchase`, `SetPriceForDirectPurchase` for tokens). + public enum ParsedBatchedTransition: Sendable, Equatable { + /// A document transition within `dataContractId` / `documentType`. + /// `amount` is a purchase price or an update-price value; + /// `recipientId` is a transfer's new owner. + case document( + dataContractId: Identifier, + documentType: String, + documentId: Identifier, + action: String, + amount: UInt64?, + recipientId: Identifier? + ) + /// A token transition on `tokenId` at `tokenContractPosition` of + /// `dataContractId`. `amount` is the transferred / minted / burned + /// token count or a direct purchase's total agreed price; + /// `recipientId` is a transfer's recipient, a mint's issued-to + /// identity, or the frozen identity of a freeze / unfreeze / + /// destroy. Interpret both against `action`: a `Freeze` sheet + /// says "freeze the tokens of X", not "send to X". + case token( dataContractId: Identifier, tokenId: Identifier, tokenContractPosition: UInt16, - tokenCount: UInt64, - totalAgreedPrice: UInt64 - ) { + action: String, + amount: UInt64?, + recipientId: Identifier? + ) + + /// The data contract the transition acts within. + public var dataContractId: Identifier { + switch self { + case .document(let id, _, _, _, _, _), .token(let id, _, _, _, _, _): + return id + } + } + + /// The rs-dpp action name. + public var action: String { + switch self { + case .document(_, _, _, let action, _, _), .token(_, _, _, let action, _, _): + return action + } + } + } + + /// Inspectable fields of a parsed `BatchTransition`. + public struct ParsedBatchTransition: Sendable, Equatable { + /// The identity whose keys sign and whose credits pay. + public let ownerId: Identifier + /// One entry per batched transition, in order. + public let transitions: [ParsedBatchedTransition] + + public init(ownerId: Identifier, transitions: [ParsedBatchedTransition]) { self.ownerId = ownerId - self.dataContractId = dataContractId - self.tokenId = tokenId - self.tokenContractPosition = tokenContractPosition - self.tokenCount = tokenCount - self.totalAgreedPrice = totalAgreedPrice + self.transitions = transitions } } - /// One parsed `dash-st:` state transition, discriminated by case - /// so callers branch on the payload instead of on thrown errors. - public enum ParsedStateTransition: Sendable { - /// DashConnect key registration (`IdentityUpdateTransition`). + /// Inspectable fields of a parsed `IdentityCreditTransferTransition`. + public struct ParsedCreditTransferTransition: Sendable, Equatable { + public let identityId: Identifier + public let recipientId: Identifier + public let amount: UInt64 + + public init(identityId: Identifier, recipientId: Identifier, amount: UInt64) { + self.identityId = identityId + self.recipientId = recipientId + self.amount = amount + } + } + + /// Inspectable fields of a parsed `DataContractCreateTransition` or + /// `DataContractUpdateTransition`. + public struct ParsedDataContractTransition: Sendable, Equatable { + public let contractId: Identifier + public let ownerId: Identifier + /// Document type names the contract defines, as the contract + /// orders them. + public let documentTypeNames: [String] + + public init(contractId: Identifier, ownerId: Identifier, documentTypeNames: [String]) { + self.contractId = contractId + self.ownerId = ownerId + self.documentTypeNames = documentTypeNames + } + } + + /// The typed summary of one parsed state transition, discriminated + /// by kind so callers branch on the payload instead of on thrown + /// errors. Kinds this SDK has no describer for arrive as `.other`. + public enum ParsedStateTransitionKind: Sendable, Equatable { + /// Key registration / revocation (`IdentityUpdateTransition`). case identityUpdate(ParsedIdentityUpdateTransition) - /// dApp token purchase (a `BatchTransition` carrying exactly - /// one `TokenDirectPurchase`). - case tokenPurchase(ParsedTokenPurchaseTransition) + /// Document and token operations (`BatchTransition`). + case batch(ParsedBatchTransition) + /// `IdentityCreditTransferTransition`. + case creditTransfer(ParsedCreditTransferTransition) + /// `DataContractCreateTransition`. + case dataContractCreate(ParsedDataContractTransition) + /// `DataContractUpdateTransition`. + case dataContractUpdate(ParsedDataContractTransition) + /// Any other kind; see `ParsedStateTransition.kindName`. + case other + } + + /// One parsed state transition (a `dash-st:` payload or a DashPay + /// Connect `sign` request), decoded whatever its kind. + /// + /// `serialized` holds the bytes that actually decoded, always in + /// tagged DPP framing (the variant tag is prepended when the input + /// arrived tagless); after approval, sign these rather than the + /// input so what was shown is what is signed. A `sign` request must + /// arrive with `isSigned == false` and `ownerId` equal to the + /// wallet's own identity; both checks are the caller's. + public struct ParsedStateTransition: Sendable, Equatable { + /// rs-dpp `StateTransition::name()`, e.g. `IdentityUpdate`, + /// `DocumentsBatch([Create, TokenTransfer])`, `MasternodeVote`. + public let kindName: String + /// The identity the transition acts for; `nil` for the + /// asset-lock-funded and shielded kinds, which name none. + public let ownerId: Identifier? + /// Whether the transition already carries a signature. + public let isSigned: Bool + /// The decoded bytes, tagged. + public let serialized: Data + /// The typed summary. + public let kind: ParsedStateTransitionKind + + public init( + kindName: String, + ownerId: Identifier?, + isSigned: Bool, + serialized: Data, + kind: ParsedStateTransitionKind + ) { + self.kindName = kindName + self.ownerId = ownerId + self.isSigned = isSigned + self.serialized = serialized + self.kind = kind + } } /// Result of a successful identity registration. @@ -1252,16 +1357,22 @@ extension ManagedPlatformWallet { var row = IdentityKeyPreviewFFI() - let result = self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in - let walletPtr = walletBytes.bindMemory(to: UInt8.self).baseAddress! - return dash_sdk_derive_identity_key_at_slot_with_resolver( - network.ffiValue, - walletPtr, - resolver.handle, - identityIndex, - keyId, - &row - ) + // `withExtendedLifetime` pins the resolver across the synchronous + // FFI call so ARC cannot deallocate its `passUnretained` ctx while + // Rust is still calling back into it (same rationale as + // `coreAddressPrivateKey`). + let result = withExtendedLifetime(resolver) { + self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in + let walletPtr = walletBytes.bindMemory(to: UInt8.self).baseAddress! + return dash_sdk_derive_identity_key_at_slot_with_resolver( + network.ffiValue, + walletPtr, + resolver.handle, + identityIndex, + keyId, + &row + ) + } } defer { dash_sdk_derive_identity_key_at_slot_free(&row) } @@ -1295,6 +1406,132 @@ extension ManagedPlatformWallet { ) } + /// A DashPay Connect key derived at a DIP-13 sub-feature path (see + /// `deriveConnectKey(subFeature:identityId:leaf:purpose:)`). + public struct ConnectDerivedKey: Sendable, Equatable { + /// 33-byte compressed secp256k1 public key. + public let publicKeyData: Data + /// 32-byte private scalar. Hand it to the Keychain (or encrypt it + /// to the app) and drop this value as soon as possible. + public let privateKeyData: Data + + public init(publicKeyData: Data, privateKeyData: Data) { + self.publicKeyData = publicKeyData + self.privateKeyData = privateKeyData + } + } + + /// DIP-13 sub-features DashPay Connect v2 keys live under + /// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`). + /// Registered by the DIP-13 amendment dashpay/dips#191. + public enum ConnectSubFeature: UInt32, Sendable { + /// Session authentication key: the leaf is the connect request id + /// (`hash256` of the app's ephemeral public key); no purpose level. + case sessionAuthentication = 6 + /// App encryption key pair: the leaf is the id of the data + /// contract the key is bound to; the purpose level is + /// `ConnectKeyPurpose.encryption` (1') or `.decryption` (2'). + case appEncryption = 7 + } + + /// The trailing `purpose'` level of the app-encryption path: the DPP + /// purpose discriminant of the half being derived. Only these two + /// exist in the DIP-13 amendment, so the type makes any other value + /// (in particular `0`, which would collide with the FFI's "no purpose + /// level") unrepresentable. + public enum ConnectKeyPurpose: UInt32, Sendable { + case encryption = 1 + case decryption = 2 + + /// The DPP `KeyPurpose` the derived half is registered under. + public var keyPurpose: KeyPurpose { + switch self { + case .encryption: return .encryption + case .decryption: return .decryption + } + } + } + + /// Derive a DashPay Connect key at + /// `m/9'/coin'/5'/'/0'/'/'[/']` + /// from this wallet's seed, resolved on demand through the + /// `MnemonicResolver` (the mnemonic never lives in a Swift `String` + /// outside the resolver trampoline; see `deriveIdentityAuthKeyAtSlot`). + /// + /// `identityId` and `leaf` are DIP-14 256-bit hardened children, so + /// nothing wallet-local is an input and two devices restored from one + /// seed derive the same key. `purpose`, when given, appends one more + /// hardened child: the encryption sub-feature uses `.encryption` and + /// `.decryption` to split its pair; the authentication sub-feature + /// takes `nil` (no purpose level at all). + /// + /// - Parameters: + /// - subFeature: `.sessionAuthentication` or `.appEncryption`. + /// - identityId: the identity's 32-byte id. + /// - leaf: the 32-byte request id or bound contract id. + /// - purpose: the half of an encryption pair to derive, or `nil`. + /// - network: the wallet's network, which selects the coin type. + /// - storage: defaults to a fresh `WalletStorage()`; overridable + /// for tests. Used by the resolver vtable. + @MainActor + public func deriveConnectKey( + subFeature: ConnectSubFeature, + identityId: Identifier, + leaf: Data, + purpose: ConnectKeyPurpose? = nil, + network: Network, + storage: WalletStorage = WalletStorage() + ) throws -> ConnectDerivedKey { + guard self.walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be 32 bytes, got \(self.walletId.count)" + ) + } + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be 32 bytes, got \(identityId.count)" + ) + } + guard leaf.count == 32 else { + throw PlatformWalletError.invalidParameter( + "leaf must be 32 bytes, got \(leaf.count)" + ) + } + let resolver = MnemonicResolver(storage: storage) + + var out = ConnectDerivedKeyFFI() + defer { dash_sdk_derive_connect_key_free(&out) } + + // `withExtendedLifetime` pins the resolver across the synchronous + // FFI call so ARC cannot deallocate its `passUnretained` ctx while + // Rust is still calling back into it. + let result = withExtendedLifetime(resolver) { + self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in + identityId.withUnsafeBytes { identityBytes -> PlatformWalletFFIResult in + leaf.withUnsafeBytes { leafBytes -> PlatformWalletFFIResult in + dash_sdk_derive_connect_key_with_resolver( + network.ffiValue, + walletBytes.bindMemory(to: UInt8.self).baseAddress!, + resolver.handle, + subFeature.rawValue, + identityBytes.bindMemory(to: UInt8.self).baseAddress!, + leafBytes.bindMemory(to: UInt8.self).baseAddress!, + purpose?.rawValue ?? 0, + &out + ) + } + } + } + } + try result.check() + + // Copy out of the inline tuples: the deferred free zeroizes them. + return ConnectDerivedKey( + publicKeyData: Self.tupleData(out.public_key_bytes), + privateKeyData: Self.tupleData(out.private_key_bytes) + ) + } + /// Pre-derive + pre-persist the authentication keys an upcoming /// `registerIdentityFromAddresses(...signer:)` call will use. /// @@ -3378,21 +3615,19 @@ extension ManagedPlatformWallet { } /// Parse a raw DPP state transition handed to the wallet by a dApp - /// (DashConnect `dash-st:` link / QR) without signing or - /// broadcasting it, reporting which supported kind it found. - /// Accepts both standard tagged bytes and Yappr's tagless framing. - /// - /// Supported kinds: an `IdentityUpdateTransition` (DashConnect key - /// registration) and a `BatchTransition` carrying exactly one - /// `TokenDirectPurchase` (a dApp token purchase). Anything else — - /// including multi-transition or mixed batches, which a user - /// cannot meaningfully approve as one prompt — throws with a - /// message naming what was found. - /// - /// The wallet never signs bytes a web page handed it. After the - /// user approves the parsed intent, rebuild and sign the - /// operation through the normal path: `tokenPurchase(...)` for - /// `.tokenPurchase`, `updateIdentity(...)` for `.identityUpdate`. + /// (a DashConnect `dash-st:` link / QR or a DashPay Connect `sign` + /// request) without signing or broadcasting it. Every kind decodes; + /// the result carries a typed summary for identity updates, batches, + /// credit transfers and data contract create / update, and `.other` + /// with the raw kind name for everything else, so the approval + /// sheet can describe what is asked and the user decides. Accepts + /// both standard tagged bytes and Yappr's tagless framing; the + /// returned `serialized` bytes are always tagged. + /// + /// The wallet never signs bytes a web page handed it blind. A + /// `sign` request must be unsigned and for the wallet's own + /// identity; check `isSigned` and `ownerId` before showing the + /// sheet, then sign `serialized` after approval. public func parseStateTransition(_ bytes: Data) throws -> ParsedStateTransition { guard !bytes.isEmpty else { throw PlatformWalletError.deserialization( @@ -3415,33 +3650,163 @@ extension ManagedPlatformWallet { try result.check() defer { platform_wallet_parse_state_transition_free(&out) } - switch out.kind { + return try Self.makeParsedStateTransition(from: out) + } + + /// Copy a fixed-size C byte tuple — how Swift imports a `uint8_t[N]` + /// field — out of an FFI struct into `Data`. The tuples are plain + /// bytes, so the value's raw representation is exactly the array it + /// stands for, and `Data` copies before the FFI free wipes the source. + fileprivate static func tupleData(_ tuple: Tuple) -> Data { + Swift.withUnsafeBytes(of: tuple) { Data($0) } + } + + // `internal` so the projection can be covered directly from a + // hand-built C struct; every production caller reaches it through + // a live FFI parse. + static func makeParsedStateTransition( + from ffi: ParsedStateTransitionFFI + ) throws -> ParsedStateTransition { + let kind: ParsedStateTransitionKind + switch ffi.kind { case 1: // PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE - return .identityUpdate( - try Self.makeParsedIdentityUpdateTransition(from: out.identity_update) + kind = .identityUpdate( + try makeParsedIdentityUpdateTransition(from: ffi.identity_update) + ) + case 2: // PARSED_STATE_TRANSITION_KIND_BATCH + kind = .batch(try makeParsedBatchTransition(from: ffi.batch)) + case 3: // PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER + kind = .creditTransfer( + ParsedCreditTransferTransition( + identityId: tupleData(ffi.credit_transfer.identity_id), + recipientId: tupleData(ffi.credit_transfer.recipient_id), + amount: ffi.credit_transfer.amount + ) + ) + case 4: // PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + kind = .dataContractCreate( + try makeParsedDataContractTransition(from: ffi.data_contract) + ) + case 5: // PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE + kind = .dataContractUpdate( + try makeParsedDataContractTransition(from: ffi.data_contract) + ) + case 255: // PARSED_STATE_TRANSITION_KIND_OTHER + kind = .other + default: + throw PlatformWalletError.deserialization( + "Unknown parsed state-transition kind \(ffi.kind)" + ) + } + + guard let kindNamePtr = ffi.kind_name, + let kindName = String(validatingCString: kindNamePtr) else { + throw PlatformWalletError.deserialization( + "Parsed state transition is missing a valid UTF-8 kind name" + ) + } + + let ownerId: Identifier? = ffi.has_owner_id ? tupleData(ffi.owner_id) : nil + + let serialized: Data + if let serializedPtr = ffi.serialized, ffi.serialized_len > 0 { + serialized = Data(bytes: serializedPtr, count: Int(ffi.serialized_len)) + } else { + serialized = Data() + } + + return ParsedStateTransition( + kindName: kindName, + ownerId: ownerId, + isSigned: ffi.is_signed, + serialized: serialized, + kind: kind + ) + } + + private static func makeParsedBatchTransition( + from ffi: ParsedBatchFFI + ) throws -> ParsedBatchTransition { + let ownerId = tupleData(ffi.owner_id) + + var transitions: [ParsedBatchedTransition] = [] + if let pointer = ffi.transitions, ffi.transitions_count > 0 { + let buffer = UnsafeBufferPointer(start: pointer, count: Int(ffi.transitions_count)) + transitions = try buffer.enumerated().map { index, entry in + try makeParsedBatchedTransition(from: entry, index: index) + } + } + return ParsedBatchTransition(ownerId: ownerId, transitions: transitions) + } + + static func makeParsedBatchedTransition( + from entry: ParsedBatchedTransitionFFI, + index: Int + ) throws -> ParsedBatchedTransition { + guard let actionPtr = entry.action, + let action = String(validatingCString: actionPtr) else { + throw PlatformWalletError.deserialization( + "Batched transition \(index) is missing a valid UTF-8 action name" ) - case 2: // PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE - let purchase = out.token_direct_purchase - var ownerTuple = purchase.owner_id - var contractTuple = purchase.data_contract_id - var tokenTuple = purchase.token_id - return .tokenPurchase( - ParsedTokenPurchaseTransition( - ownerId: Swift.withUnsafeBytes(of: &ownerTuple) { Data($0) }, - dataContractId: Swift.withUnsafeBytes(of: &contractTuple) { Data($0) }, - tokenId: Swift.withUnsafeBytes(of: &tokenTuple) { Data($0) }, - tokenContractPosition: purchase.token_contract_position, - tokenCount: purchase.token_count, - totalAgreedPrice: purchase.total_agreed_price + } + let dataContractId = tupleData(entry.data_contract_id) + let amount: UInt64? = entry.has_amount ? entry.amount : nil + let recipientId: Identifier? = entry.has_recipient ? tupleData(entry.recipient_id) : nil + + switch entry.family { + case 0: // PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT + guard let documentTypePtr = entry.document_type, + let documentType = String(validatingCString: documentTypePtr) else { + throw PlatformWalletError.deserialization( + "Batched document transition \(index) is missing a valid UTF-8 document type" ) + } + return .document( + dataContractId: dataContractId, + documentType: documentType, + documentId: tupleData(entry.document_id), + action: action, + amount: amount, + recipientId: recipientId + ) + case 1: // PARSED_BATCHED_TRANSITION_FAMILY_TOKEN + return .token( + dataContractId: dataContractId, + tokenId: tupleData(entry.token_id), + tokenContractPosition: entry.token_contract_position, + action: action, + amount: amount, + recipientId: recipientId ) default: throw PlatformWalletError.deserialization( - "Unknown parsed state-transition kind \(out.kind)" + "Unknown batched transition family \(entry.family) at index \(index)" ) } } + private static func makeParsedDataContractTransition( + from ffi: ParsedDataContractFFI + ) throws -> ParsedDataContractTransition { + var names: [String] = [] + if let pointer = ffi.document_type_names, ffi.document_type_names_count > 0 { + let buffer = UnsafeBufferPointer(start: pointer, count: Int(ffi.document_type_names_count)) + names = try buffer.enumerated().map { index, namePtr in + guard let namePtr, let name = String(validatingCString: namePtr) else { + throw PlatformWalletError.deserialization( + "Data contract document type name \(index) is not valid UTF-8" + ) + } + return name + } + } + return ParsedDataContractTransition( + contractId: tupleData(ffi.contract_id), + ownerId: tupleData(ffi.owner_id), + documentTypeNames: names + ) + } + private static func makeParsedIdentityUpdateTransition( from ffi: ParsedIdentityUpdateFFI ) throws -> ParsedIdentityUpdateTransition { @@ -3477,7 +3842,7 @@ extension ManagedPlatformWallet { ) } - private static func makeParsedIdentityPubkey( + static func makeParsedIdentityPubkey( from entry: ParsedIdentityUpdatePublicKeyFFI, index: Int ) throws -> IdentityPubkey { @@ -3509,7 +3874,9 @@ extension ManagedPlatformWallet { securityLevel: securityLevel, pubkeyBytes: pubkeyBytes, readOnly: entry.read_only, - contractBounds: contractBounds + contractBounds: contractBounds, + totalBudget: entry.has_total_budget ? entry.total_budget : nil, + expiresAt: entry.has_expires_at ? entry.expires_at : nil ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift new file mode 100644 index 00000000000..74948c87997 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift @@ -0,0 +1,141 @@ +import XCTest + +@testable import SwiftDashSDK + +/// DashPay Connect key derivation at the DIP-13 sub-feature paths +/// (`m/9'/coin'/5'/{6|7}'/0'/identityId'/leaf'[/purpose']`, dashpay/dips#191) +/// through `ManagedPlatformWallet.deriveConnectKey`. The fixed vectors are +/// the ones `platform-wallet`'s +/// `connect_keypair_is_deterministic_from_fixed_vectors` pins on the Rust +/// side, so a drift on either side of the FFI is caught. +@MainActor +final class ConnectKeyDerivationTests: XCTestCase { + + // Canonical BIP-39 test vector (all-zero entropy). + private let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private let walletId = Data(repeating: 0x07, count: 32) + private let identityId = Data(repeating: 0x35, count: 32) + private let leaf = Data(repeating: 0x6B, count: 32) + + /// Pinned in `identity_handle.rs`: testnet, sub-feature 6', no purpose. + private let testnetAuthPubkeyHex = + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d" + /// Pinned in `identity_handle.rs`: mainnet, sub-feature 7', purpose 1'. + private let mainnetEncryptionPubkeyHex = + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434" + + /// In-memory `WalletStorage` so the resolver never touches the macOS + /// Keychain (see `IdentityResolverSignIntegrationTests`). + private final class InMemoryWalletStorage: WalletStorage { + private var mnemonics: [Data: Data] = [:] + private let lock = NSLock() + + override func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { + lock.lock() + defer { lock.unlock() } + mnemonics[walletId] = Data(mnemonic.utf8) + } + + override func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { + lock.lock() + defer { lock.unlock() } + guard let data = mnemonics[walletId], !data.isEmpty else { + throw WalletStorageError.mnemonicNotFound + } + return data + } + + override func deleteMnemonic(for walletId: Data) throws { + lock.lock() + defer { lock.unlock() } + mnemonics[walletId] = nil + } + + override func mnemonicAvailability(for walletId: Data) -> MnemonicAvailability { + lock.lock() + defer { lock.unlock() } + return mnemonics[walletId] != nil ? .present : .absent + } + } + + private func makeWallet() throws -> (ManagedPlatformWallet, WalletStorage) { + let storage = InMemoryWalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + // The derive is resolver-driven and never touches the native + // wallet handle, so a null handle is fine here. + return (ManagedPlatformWallet(handle: NULL_HANDLE, walletId: walletId), storage) + } + + func testDerivesThePinnedVectorsDeterministically() throws { + let (wallet, storage) = try makeWallet() + + let auth = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage) + let authAgain = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage) + XCTAssertEqual(auth, authAgain, "same inputs derive the same key") + XCTAssertEqual(auth.publicKeyData.count, 33) + XCTAssertEqual(auth.privateKeyData.count, 32) + XCTAssertEqual(auth.publicKeyData.toHexString(), testnetAuthPubkeyHex) + + let encryption = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, network: .mainnet, storage: storage) + XCTAssertEqual(encryption.publicKeyData.toHexString(), mainnetEncryptionPubkeyHex) + } + + func testDifferentLeavesPurposesAndSubFeaturesGiveDifferentKeys() throws { + let (wallet, storage) = try makeWallet() + + let auth = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage) + let authOtherLeaf = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, + leaf: Data(repeating: 0x6C, count: 32), network: .testnet, storage: storage) + let authOtherIdentity = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: Data(repeating: 0x36, count: 32), + leaf: leaf, network: .testnet, storage: storage) + let enc = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, network: .testnet, storage: storage) + let dec = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .decryption, network: .testnet, storage: storage) + let mainnetAuth = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .mainnet, storage: storage) + + let all = [auth, authOtherLeaf, authOtherIdentity, enc, dec, mainnetAuth] + .map { $0.publicKeyData } + XCTAssertEqual(Set(all).count, all.count, "every variation derives a distinct key") + } + + func testRejectsMalformedIdsAndAMissingMnemonic() throws { + let (wallet, storage) = try makeWallet() + + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: Data(repeating: 0x35, count: 31), + leaf: leaf, network: .testnet, storage: storage)) + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, + leaf: Data(), network: .testnet, storage: storage)) + + let empty = InMemoryWalletStorage() + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: empty) + ) { error in + guard case PlatformWalletError.walletOperation(let message) = error else { + return XCTFail("expected walletOperation, got \(error)") + } + XCTAssertTrue(message.contains("no mnemonic stored"), message) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift new file mode 100644 index 00000000000..5057c9eb7f2 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift @@ -0,0 +1,182 @@ +import XCTest + +@testable import SwiftDashSDK + +/// `ManagedPlatformWallet.parseStateTransition` decodes any DPP state +/// transition kind and returns a typed summary. The fixtures under +/// `Fixtures/StateTransitions/` are serialized on the Rust side by +/// `parse_state_transition::tests::fixture_bytes_are_pinned_for_the_client_suites` +/// (one hex line each), so the bytes decoded here are exactly the ones the +/// Rust tests decode. +@MainActor +final class ParseStateTransitionTests: XCTestCase { + + private let owner = Data(repeating: 0x21, count: 32) + private let contract = Data(repeating: 0x42, count: 32) + private let token = Data(repeating: 0x77, count: 32) + private let recipient = Data(repeating: 0x22, count: 32) + + /// The parse is a pure FFI call over the bytes; the wallet handle is + /// never touched, so a null handle is fine. + private let wallet = ManagedPlatformWallet( + handle: NULL_HANDLE, walletId: Data(repeating: 0x07, count: 32)) + + private func fixture(_ name: String) throws -> Data { + let url = try XCTUnwrap( + Bundle.module.url( + forResource: name, withExtension: "hex", + subdirectory: "Fixtures/StateTransitions"), + "missing fixture \(name)") + let hex = try String(contentsOf: url, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + return try XCTUnwrap(Data(hexString: hex), "fixture \(name) is not hex") + } + + func testParsesAMixedBatchWithOneEntryPerTransition() throws { + let bytes = try fixture("mixed_batch") + let parsed = try wallet.parseStateTransition(bytes) + + XCTAssertEqual( + parsed.kindName, "DocumentsBatch([Create, Transfer, TokenTransfer, TokenDirectPurchase])") + XCTAssertEqual(parsed.ownerId, owner) + XCTAssertFalse(parsed.isSigned) + XCTAssertEqual(parsed.serialized, bytes, "tagged input decodes as-is") + + guard case .batch(let batch) = parsed.kind else { + return XCTFail("expected .batch, got \(parsed.kind)") + } + XCTAssertEqual(batch.ownerId, owner) + XCTAssertEqual(batch.transitions.count, 4) + + XCTAssertEqual( + batch.transitions[0], + .document( + dataContractId: contract, documentType: "post", + documentId: Data(repeating: 0x0D, count: 32), action: "Create", + amount: nil, recipientId: nil)) + XCTAssertEqual( + batch.transitions[1], + .document( + dataContractId: contract, documentType: "profile", + documentId: Data(repeating: 0x0D, count: 32), action: "Transfer", + amount: nil, recipientId: recipient)) + XCTAssertEqual( + batch.transitions[2], + .token( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "Transfer", amount: 250, recipientId: recipient)) + XCTAssertEqual( + batch.transitions[3], + .token( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "DirectPurchase", amount: 100_000_000, recipientId: nil)) + XCTAssertEqual(batch.transitions.map(\.action), ["Create", "Transfer", "Transfer", "DirectPurchase"]) + } + + func testParsesATaglessBatchAndReturnsTaggedBytes() throws { + let tagged = try fixture("mixed_batch") + XCTAssertEqual(tagged.first, 2, "StateTransition::Batch variant tag") + let parsed = try wallet.parseStateTransition(tagged.dropFirst()) + + guard case .batch = parsed.kind else { + return XCTFail("expected .batch, got \(parsed.kind)") + } + XCTAssertEqual(parsed.serialized, tagged, "the variant tag is restored") + } + + func testParsesAnIdentityUpdateWithKeyLimitsAndBounds() throws { + let parsed = try wallet.parseStateTransition(try fixture("identity_update")) + + XCTAssertEqual(parsed.kindName, "IdentityUpdate") + XCTAssertEqual(parsed.ownerId, Data(repeating: 0x11, count: 32)) + XCTAssertTrue(parsed.isSigned) + + guard case .identityUpdate(let update) = parsed.kind else { + return XCTFail("expected .identityUpdate, got \(parsed.kind)") + } + XCTAssertEqual(update.identityId, Data(repeating: 0x11, count: 32)) + XCTAssertEqual(update.disablePublicKeyIds, [4, 8]) + XCTAssertEqual(update.addPublicKeys.count, 2) + + let plain = update.addPublicKeys[0] + XCTAssertEqual(plain.keyId, 17) + XCTAssertNil(plain.totalBudget) + XCTAssertNil(plain.expiresAt) + XCTAssertNil(plain.contractBounds) + + // A DashPay Connect session key: HIGH auth key bound to a contract + // group with a budget and an expiry. + let session = update.addPublicKeys[1] + XCTAssertEqual(session.keyId, 18) + XCTAssertEqual(session.purpose, .authentication) + XCTAssertEqual(session.securityLevel, .high) + XCTAssertEqual(session.keyType, .ecdsaSecp256k1) + XCTAssertEqual(session.pubkeyBytes, Data(repeating: 0x03, count: 33)) + XCTAssertEqual(session.contractBounds, .contractGroup(id: Data(repeating: 0x66, count: 32))) + XCTAssertEqual(session.totalBudget, 10_000_000_000) + XCTAssertEqual(session.expiresAt, 1_800_000_000_000) + } + + func testParsesACreditTransfer() throws { + let parsed = try wallet.parseStateTransition(try fixture("credit_transfer")) + + XCTAssertEqual(parsed.kindName, "IdentityCreditTransfer") + XCTAssertFalse(parsed.isSigned) + XCTAssertEqual( + parsed.kind, + .creditTransfer( + ParsedCreditTransferTransition( + identityId: Data(repeating: 0x11, count: 32), + recipientId: recipient, + amount: 1_000))) + } + + func testParsesDataContractCreateAndUpdate() throws { + for (name, kindName) in [ + ("data_contract_create", "DataContractCreate"), + ("data_contract_update", "DataContractUpdate"), + ] { + let parsed = try wallet.parseStateTransition(try fixture(name)) + XCTAssertEqual(parsed.kindName, kindName) + XCTAssertEqual(parsed.ownerId, owner) + + let contract: ManagedPlatformWallet.ParsedDataContractTransition + switch parsed.kind { + case .dataContractCreate(let c) where name == "data_contract_create": contract = c + case .dataContractUpdate(let c) where name == "data_contract_update": contract = c + default: return XCTFail("unexpected kind \(parsed.kind) for \(name)") + } + XCTAssertEqual(contract.ownerId, owner) + XCTAssertEqual(contract.contractId.count, 32) + // The rs-dpp fixture contract's document types, as the contract + // orders them. + XCTAssertEqual( + contract.documentTypeNames, + [ + "indexedDocument", "niceDocument", "noTimeDocument", + "optionalUniqueIndexedDocument", "prettyDocument", "uniqueDates", + "withByteArrays", + ]) + } + } + + func testRejectsEmptyAndMalformedBytes() { + XCTAssertThrowsError(try wallet.parseStateTransition(Data())) + XCTAssertThrowsError(try wallet.parseStateTransition(Data([0xDE, 0xAD, 0xBE, 0xEF]))) { error in + guard case PlatformWalletError.deserialization = error else { + return XCTFail("expected deserialization, got \(error)") + } + } + } + + /// The Swift projection of a hand-built C struct: an unknown kind tag + /// is an error, not silently `.other`, so a Rust-side addition that + /// the Swift switch does not know about cannot be mis-described. + func testUnknownKindTagIsAnError() { + var ffi = ParsedStateTransitionFFI() + ffi.kind = 77 + XCTAssertThrowsError(try ManagedPlatformWallet.makeParsedStateTransition(from: ffi)) + } +} + +private typealias ParsedCreditTransferTransition = ManagedPlatformWallet.ParsedCreditTransferTransition