From 21d77cfd67e602fffdd96e826079249c7392d498 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 18:49:13 -0500 Subject: [PATCH 01/20] 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 e9cdf4bce92..a056d49c876 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 f6e6c416907..66270ba5079 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -272,6 +272,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>( @@ -321,3 +332,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 bc5d0e76e0c0ef002c7a12ef88f0887284abb79d Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 18:49:36 -0500 Subject: [PATCH 02/20] 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 7b568a5bf78..6699955ac99 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 — @@ -638,4 +748,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 66be6d90543..75ed2a513e7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -101,9 +101,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 7661ae708472aa7633d45f8bb735f0d4e486c169 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 18:59:49 -0500 Subject: [PATCH 03/20] 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 97f51a2bbebd3297c852b0e445a0cde773f057a4 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 19:00:21 -0500 Subject: [PATCH 04/20] 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 84af3bdb6e2..1d582cfc678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6779,6 +6779,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 20c31492e1c..af933655de6 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 dc0c357cff4..a328b0e9396 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 560891952b6..fd722ca80ec 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 587fe126494..f568cbaf56e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -186,7 +186,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 @@ -254,7 +254,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] @@ -270,49 +270,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. @@ -1274,16 +1379,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) } @@ -1317,6 +1428,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. /// @@ -3525,21 +3762,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( @@ -3562,33 +3797,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" ) - 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 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" + ) + } + 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 { @@ -3624,7 +3989,7 @@ extension ManagedPlatformWallet { ) } - private static func makeParsedIdentityPubkey( + static func makeParsedIdentityPubkey( from entry: ParsedIdentityUpdatePublicKeyFFI, index: Int ) throws -> IdentityPubkey { @@ -3656,7 +4021,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 From f45002fc709cf2a629e24e754aeba6a007d059b6 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 22 Sep 2026 20:48:47 -0500 Subject: [PATCH 05/20] refactor(platform-wallet)!: summarize dApp state transitions in platform-wallet and address review findings Moves decoding and the approval summary of a dApp-provided state transition out of platform-wallet-ffi into platform-wallet (summarize_state_transition / StateTransitionSummary), on top of rs-dpp's exact untrusted decode and per-kind untagged decode (#4931). The FFI keeps only the C projection and no longer hardcodes bincode variant tags. What the user is shown before signing: trailing bytes and prefix decodes no longer count as a framing; user_fee_increase and a top-level complete flag (StateTransitionSummary::is_complete) are part of every summary; per-row details render document data, a document base's token payment and action fee agreement, token config update / emergency action / price schedule / claim distribution, quoted public notes, encrypted-note presence, group info and a mint to the default destination; data contract create / update and kinds without a describer carry the whole decoded value in details and are never complete. Clients: Kotlin initializes the native library before parsing, scrubs a derived Connect key discarded by cancellation, and reads the u32 kind name and complete flag (goldens regenerated); Swift reads the FFI constants for kinds, families and Connect sub-feature / purpose; a JNI test pins Kotlin's Connect constants to the FFI's. The Connect derive reuses the shared mnemonic resolver helper and erases the master key on every path. Key-limit conversion tests now live in rs-dpp. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dashsdk/identity/StateTransitionParser.kt | 84 +- .../dashsdk/wallet/PlatformWalletManager.kt | 7 +- .../identity/StateTransitionParserTest.kt | 118 +- .../golden/parsed_identity_update_v1.bin | Bin 329 -> 507 bytes .../golden/parsed_mixed_batch_v1.bin | Bin 0 -> 855 bytes .../golden/parsed_token_transfer_batch_v1.bin | Bin 372 -> 387 bytes .../src/derive_connect_key.rs | 93 +- .../src/identity_registration_with_signer.rs | 31 - .../src/identity_update.rs | 74 +- .../src/parse_state_transition.rs | 675 +++++------ packages/rs-platform-wallet/Cargo.toml | 2 + .../src/wallet/identity/network/mod.rs | 6 + .../network/state_transition_summary.rs | 1011 +++++++++++++++++ .../src/wallet/identity/network/update.rs | 111 -- packages/rs-unified-sdk-jni/src/identity.rs | 45 +- .../src/parse_state_transition.rs | 375 ++++-- .../ManagedPlatformWallet.swift | 269 ++++- .../credit_transfer_max_fee.hex | 1 + .../StateTransitions/credit_withdrawal.hex | 1 + .../StateTransitions/data_contract_create.hex | 2 +- .../StateTransitions/data_contract_update.hex | 2 +- .../ParseStateTransitionTests.swift | 76 +- 22 files changed, 2158 insertions(+), 825 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer_max_fee.hex create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex 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 index cb4d9449ce7..a13351bc64a 100644 --- 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 @@ -1,6 +1,7 @@ package org.dashfoundation.dashsdk.identity import java.nio.ByteBuffer +import org.dashfoundation.dashsdk.Sdk import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashSDKException import org.dashfoundation.dashsdk.ffi.TransactionsNative @@ -18,6 +19,19 @@ import org.dashfoundation.dashsdk.ffi.TransactionsNative sealed class ParsedBatchedTransition { abstract val dataContractId: ByteArray abstract val action: String + /** + * Whether the typed fields describe every material field of the + * transition. **A row with `complete == false` must not be approved from + * the typed fields alone; the wallet must render [details].** + */ + abstract val complete: Boolean + /** + * The material fields the typed fields do not cover (document data, a + * token config change and who it grants or revokes, an emergency action, + * a price schedule, a distribution type, notes, group-action info), + * rendered for display. Null when [complete]. + */ + abstract val details: String? /** Credits or tokens the transition moves, when it moves any. */ abstract val amount: Long? /** @@ -39,6 +53,8 @@ sealed class ParsedBatchedTransition { override val action: String, override val amount: Long?, override val recipientId: ByteArray?, + override val complete: Boolean, + override val details: String?, ) : ParsedBatchedTransition() { override fun equals(other: Any?): Boolean = other is Document && @@ -47,7 +63,9 @@ sealed class ParsedBatchedTransition { documentId.contentEquals(other.documentId) && action == other.action && amount == other.amount && - recipientId.contentEqualsNullable(other.recipientId) + recipientId.contentEqualsNullable(other.recipientId) && + complete == other.complete && + details == other.details override fun hashCode(): Int = listOf(dataContractId.contentHashCode(), documentType, documentId.contentHashCode(), action) @@ -70,6 +88,10 @@ sealed class ParsedBatchedTransition { override val action: String, override val amount: Long?, override val recipientId: ByteArray?, + /** A `DirectPurchase`'s token count ([amount] is its total agreed price). */ + val tokenCount: Long?, + override val complete: Boolean, + override val details: String?, ) : ParsedBatchedTransition() { override fun equals(other: Any?): Boolean = other is Token && @@ -78,7 +100,10 @@ sealed class ParsedBatchedTransition { tokenContractPosition == other.tokenContractPosition && action == other.action && amount == other.amount && - recipientId.contentEqualsNullable(other.recipientId) + recipientId.contentEqualsNullable(other.recipientId) && + tokenCount == other.tokenCount && + complete == other.complete && + details == other.details override fun hashCode(): Int = listOf(dataContractId.contentHashCode(), tokenId.contentHashCode(), tokenContractPosition, action) @@ -134,10 +159,10 @@ class ParsedDataContract( * `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` + * [serialized] holds the decoded transition re-serialized in tagged DPP + * framing; after approval, sign these rather than the input so what was + * shown is what is signed. When [complete] is false, [details] (or the + * batch rows' details) must be shown before approval. A `sign` request must arrive with `isSigned == false` * and [ownerId] equal to the wallet's own identity; both checks are the * caller's. */ @@ -148,8 +173,23 @@ class ParsedStateTransition( val ownerId: ByteArray?, /** Whether the transition already carries a signature. */ val isSigned: Boolean, + /** + * Percentage added to the processing fee Platform charges (0 = none; + * 65535 is about 656 times the base processing fee). Part of the signed + * bytes: show a non-zero value on the sheet and refuse values above what + * the wallet is willing to pay. + */ + val userFeeIncrease: Int, + /** Whether [kind] shows every material field; computed in Rust. */ + val complete: Boolean, /** The decoded bytes, tagged. */ val serialized: ByteArray, + /** + * A structured multi-line dump: of the whole transition for + * [ParsedStateTransitionKind.Other], of the whole contract for a data + * contract create / update. Null otherwise. + */ + val details: String?, val kind: ParsedStateTransitionKind, ) @@ -180,6 +220,11 @@ object StateTransitionParser { */ fun parse(transitionBytes: ByteArray): ParsedStateTransition { require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" } + // This is a handle-free utility, so it may well be the first SDK + // call in the process; load the native library and run dash_sdk_init + // before touching JNI (as TransactionDecoder.decode does), otherwise + // the external fun resolves to UnsatisfiedLinkError. + Sdk.initialize() val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) } return try { parseBlob(blob) @@ -206,10 +251,13 @@ object StateTransitionParser { 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 kindName = readString32(buf) val ownerId = if (readBool(buf)) readId32(buf) else null val isSigned = readBool(buf) + val userFeeIncrease = buf.short.toInt() and 0xFFFF + val complete = readBool(buf) val serialized = readBytes32Len(buf) + val details = readString32(buf).ifEmpty { null } val kind: ParsedStateTransitionKind = when (kindTag) { KIND_IDENTITY_UPDATE -> { @@ -234,7 +282,9 @@ object StateTransitionParser { } require(!buf.hasRemaining()) { "malformed parse blob: trailing bytes" } - return ParsedStateTransition(kindName, ownerId, isSigned, serialized, kind) + return ParsedStateTransition( + kindName, ownerId, isSigned, userFeeIncrease, complete, serialized, details, kind, + ) } private fun readPublicKey(buf: ByteBuffer): IdentityPubkey { @@ -285,14 +335,25 @@ object StateTransitionParser { 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) + val tokenCount = if (readBool(buf)) buf.long else null + require(tokenCount == null) { "malformed parse blob: token count on a document row" } + val complete = readBool(buf) + val details = readString32(buf).ifEmpty { null } + ParsedBatchedTransition.Document( + dataContractId, documentType, documentId, action, amount, recipient, complete, details, + ) } 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) + val tokenCount = if (readBool(buf)) buf.long else null + val complete = readBool(buf) + val details = readString32(buf).ifEmpty { null } + ParsedBatchedTransition.Token( + dataContractId, tokenId, position, action, amount, recipient, tokenCount, complete, details, + ) } else -> throw IllegalArgumentException("malformed parse blob: batched family $family") } @@ -322,6 +383,9 @@ object StateTransitionParser { return String(bytes, Charsets.UTF_8) } + /** `u32 len + UTF-8 bytes`; an empty string encodes "none". */ + private fun readString32(buf: ByteBuffer): String = String(readBytes32Len(buf), Charsets.UTF_8) + /** `u32 len + bytes`. */ private fun readBytes32Len(buf: ByteBuffer): ByteArray { val len = buf.int 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 a18ed364506..7ed57ac7dbf 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 @@ -687,7 +687,12 @@ class PlatformWalletManager( identityId: ByteArray, leaf: ByteArray, purpose: ConnectKeyPurpose? = null, - ): Pair = teardownGate.op { + ): Pair = teardownGate.opWithCleanupOnCancellation( + // withContext's prompt cancellation can discard a completed derive + // before the caller receives the pair it is documented to wipe; scrub + // the private half on that handoff failure. + cleanup = { pair -> pair.first.fill(0) }, + ) { 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 { 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 index 43e72161677..1ba95625364 100644 --- 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 @@ -30,14 +30,31 @@ class StateTransitionParserTest { assertEquals("IdentityUpdate", parsed.kindName) assertArrayEquals(ByteArray(32) { 0x11 }, parsed.ownerId) assertFalse(parsed.isSigned) + assertEquals(0, parsed.userFeeIncrease) + assertTrue(parsed.complete) + assertNull("described kinds carry no common details", parsed.details) 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) + assertEquals(2, update.addPublicKeys.size) - val session = update.addPublicKeys.single() + // The document-type-bound key: its bounds carry a variable-length + // name before the limits flags, so the fields after it are pinned. + val bound = update.addPublicKeys[1] + assertEquals(19, bound.keyId) + assertEquals(KeyPurpose.ENCRYPTION, bound.purpose) + assertEquals(SecurityLevel.MEDIUM, bound.securityLevel) + assertTrue(bound.readOnly) + assertEquals( + ContractBounds.SingleContractDocumentType(ByteArray(32) { 0x44 }, "profile"), + bound.contractBounds, + ) + assertNull(bound.totalBudget) + assertNull(bound.expiresAt) + + val session = update.addPublicKeys[0] assertEquals(18, session.keyId) assertEquals(KeyType.ECDSA_SECP256K1, session.keyType) assertEquals(KeyPurpose.AUTHENTICATION, session.purpose) @@ -70,6 +87,9 @@ class StateTransitionParserTest { action = "Transfer", amount = 250L, recipientId = ByteArray(32) { 0x22 }, + tokenCount = null, + complete = true, + details = null, ), ), batch.transitions, @@ -77,17 +97,100 @@ class StateTransitionParserTest { } @Test - fun `decodes the other kind with only the common fields`() { - // kind 255, name "MasternodeVote", no owner, signed, empty serialized. + fun `decodes a mixed document and token batch with completeness and details`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_mixed_batch_v1.bin")) + + assertEquals("DocumentsBatch([Create, Transfer, TokenDirectPurchase])", parsed.kindName) + assertEquals(7, parsed.userFeeIncrease) + assertFalse(parsed.complete) + assertNull(parsed.details) + + val batch = parsed.kind as ParsedStateTransitionKind.Batch + assertEquals(3, batch.transitions.size) + + // Document create: incomplete, its data rendered in details; the + // fields after the two variable-length strings still line up. + val create = batch.transitions[0] as ParsedBatchedTransition.Document + assertEquals("Create", create.action) + assertEquals("post", create.documentType) + assertArrayEquals(ByteArray(32) { 0x0D }, create.documentId) + assertNull(create.amount) + assertNull(create.recipientId) + assertFalse(create.complete) + assertTrue(create.details!!.contains("\"message\"")) + + assertEquals( + ParsedBatchedTransition.Document( + dataContractId = ByteArray(32) { 0x42 }, + documentType = "profile", + documentId = ByteArray(32) { 0x0D }, + action = "Transfer", + amount = null, + recipientId = ByteArray(32) { 0x22 }, + complete = true, + details = null, + ), + batch.transitions[1], + ) + assertEquals( + ParsedBatchedTransition.Token( + dataContractId = ByteArray(32) { 0x42 }, + tokenId = ByteArray(32) { 0x77 }, + tokenContractPosition = 3, + action = "DirectPurchase", + amount = 100_000_000L, + recipientId = null, + tokenCount = 100L, + complete = true, + details = null, + ), + batch.transitions[2], + ) + } + + /** + * [StateTransitionParser.parse] is a handle-free utility and may be the + * first SDK call in a process. Without the native library it must fail + * inside [org.dashfoundation.dashsdk.Sdk.initialize] (the load step) and + * not with an [UnsatisfiedLinkError] from the external fun itself, which + * is what a forgotten initialize looks like. This host JVM has no + * `libdash_sdk_jni.so`, so the load is what fails here; on a device the + * same call path loads the library and parses. + */ + @Test + fun `parse initializes the native library before entering JNI`() { + val error = assertThrows(Throwable::class.java) { + StateTransitionParser.parse(byteArrayOf(0x07, 0x00)) + } + // System.loadLibrary failing is an UnsatisfiedLinkError whose message + // names the library; a missing native method names the method. + assertTrue( + "expected the library load to fail, got ${error::class.simpleName}: ${error.message}", + error is UnsatisfiedLinkError && error.message?.contains("dash_sdk_jni") == true, + ) + assertFalse( + "parse reached the external fun before loading the library", + error.message?.contains("parseStateTransition") == true, + ) + } + + @Test + fun `decodes the other kind with the common fields and details`() { + // kind 255, name "MasternodeVote", no owner, signed, fee increase 3, + // incomplete, empty serialized, details "vote". val name = "MasternodeVote".toByteArray() - val blob = byteArrayOf(0xFF.toByte(), 0, name.size.toByte()) + name + - byteArrayOf(0, 1, 0, 0, 0, 0) + val details = "vote".toByteArray() + val blob = byteArrayOf(0xFF.toByte(), 0, 0, 0, name.size.toByte()) + name + + byteArrayOf(0, 1, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, details.size.toByte()) + details val parsed = StateTransitionParser.parseBlob(blob) assertEquals("MasternodeVote", parsed.kindName) assertNull(parsed.ownerId) assertTrue(parsed.isSigned) + assertEquals(3, parsed.userFeeIncrease) + assertFalse(parsed.complete) assertEquals(0, parsed.serialized.size) + assertEquals("vote", parsed.details) assertTrue(parsed.kind is ParsedStateTransitionKind.Other) } @@ -106,7 +209,8 @@ class StateTransitionParserTest { @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 blob = byteArrayOf(77, 0, name.size.toByte()) + name + + byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) val error = assertThrows(IllegalArgumentException::class.java) { StateTransitionParser.parseBlob(blob) } 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 index 4f41448de1e29737ed5d098bff3969280513c4c9..07cd5cba17c9c70946a8330c81fe0bae79c9d25b 100644 GIT binary patch literal 507 zcmZQ%U|`_$Oi9fv$tRR& sFaa^hgAB|>cz{3KOU}3|$anu#zi@ph;!BDiY#hc>y-I4!yW^ zVEVtwKA-MC{l60;x?lFyZX;drr43axdwy_YgB1D7+0L)U>8XE{ZrM7i!sE_WjrH;} z=P7_F)A9V2z|K_rXaeBTw)dd~`~W!Hi1+qYZj+`ZQe_}J;cV;rb^9(rxl9azuZcz} z8nXvbeYp7jj^3(kAcLv4q=Y2t@{~D6i;*EiSjt^(gI(mG<6JR+u9`)@l8-Qp#rV*) uriizBG5*4vT%CU%oo$EfFtFVa5BoB7;pYDrrxYZyk8_7GP6{eg$I=m0kH^{o 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 index 8b2f4a77217edf671477573a11a527255433d4fc..64a2ede1c462f443fc0318e0c12f76d2476f3ddd 100644 GIT binary patch delta 49 ucmeyu)XdDx#K16-(UqN%0Z213P7IWsn7}?UE1w0(XMpm4R{=&q_G?Cerg<&ES = - 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 wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .expect("from_raw_parts(_, 32) always yields exactly 32 bytes"); + let mut master = unwrap_result_or_return!(resolve_master_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + kw_network, + )); - let derived = unwrap_result_or_return!(derive_connect_keypair_from_master( + let derived = derive_connect_keypair_from_master( &master, kw_network, sub_feature, &identity_id, leaf, purpose, - )); + ); + // `ExtendedPrivKey` does not wipe itself; erase before any return. + master.private_key.non_secure_erase(); + let derived = unwrap_result_or_return!(derived); (*out_key).private_key_bytes = *derived.private_key; (*out_key).public_key_bytes = derived.public_key; @@ -226,9 +193,13 @@ pub unsafe extern "C" fn dash_sdk_derive_connect_key_free(out_key: *mut ConnectD mod tests { use super::*; use dpp::prelude::Identifier; + use key_wallet::bip32::ExtendedPrivKey; use key_wallet::mnemonic::Mnemonic; + use rs_sdk_ffi::mnemonic_resolver_result; use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use std::ffi::c_void; use std::ffi::CStr; + use std::os::raw::c_char; /// English BIP-39 test vector (all-zero entropy); the same fixture the /// platform-wallet derivation tests pin vectors for. 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 a056d49c876..1840c3be90c 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 @@ -1021,37 +1021,6 @@ mod tests { 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-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 623ef570548..d7eda4950b0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -12,13 +12,18 @@ use std::slice; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::KeyID; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; +use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; use dpp::state_transition::public_key_in_creation::accessors::{ IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, }; -use dpp::state_transition::StateTransition; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::{StateTransition, StateTransitionType}; +use platform_wallet::wallet::identity::network::decode_state_transition; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -31,9 +36,6 @@ use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; -/// Positional bincode variant tag of `StateTransition::IdentityUpdate`. -pub(crate) const IDENTITY_UPDATE_VARIANT_TAG: u8 = 6; - /// Owned C representation of one public key carried by a parsed /// `IdentityUpdateTransition`. /// @@ -92,23 +94,17 @@ impl Default for ParsedIdentityUpdateFFI { fn parse_identity_update_transition_bytes( bytes: &[u8], -) -> Result< - dpp::state_transition::identity_update_transition::IdentityUpdateTransition, - PlatformWalletFFIResult, -> { - // Tolerates Yappr's tagless framing next to standard tagged bytes — see - // the shared helper for the ordering heuristic. +) -> Result { + // Tolerates Yappr's untagged identity updates next to standard tagged bytes. let (state_transition, _tagged_bytes) = - crate::parse_state_transition::deserialize_transition_with_flexible_framing( - bytes, - &[(IDENTITY_UPDATE_VARIANT_TAG, "IdentityUpdate")], - )?; + decode_state_transition(bytes, &[StateTransitionType::IdentityUpdate]) + .map_err(crate::parse_state_transition::deserialization_error)?; match state_transition { StateTransition::IdentityUpdate(identity_update) => Ok(identity_update), other => Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("Expected IdentityUpdateTransition, got {other:?}"), + format!("Expected IdentityUpdateTransition, got {}", other.name()), )), } } @@ -164,11 +160,12 @@ unsafe fn free_parsed_public_keys(keys: &mut [ParsedIdentityUpdatePublicKeyFFI]) } pub(crate) fn project_parsed_identity_update( - transition: &dpp::state_transition::identity_update_transition::IdentityUpdateTransition, + identity_id: &Identifier, + public_keys_to_add: &[IdentityPublicKeyInCreation], + public_key_ids_to_disable: &[KeyID], ) -> Result { - let identity_id = transition.identity_id().to_buffer(); + let identity_id = identity_id.to_buffer(); - let public_keys_to_add = transition.public_keys_to_add(); let mut add_public_keys_vec: Vec = Vec::with_capacity(public_keys_to_add.len()); @@ -216,7 +213,7 @@ pub(crate) fn project_parsed_identity_update( as *mut ParsedIdentityUpdatePublicKeyFFI }; - let disable_public_key_ids_vec = transition.public_key_ids_to_disable().to_vec(); + let disable_public_key_ids_vec = public_key_ids_to_disable.to_vec(); let disable_public_key_ids_count = disable_public_key_ids_vec.len(); let disable_public_key_ids = if disable_public_key_ids_count == 0 { ptr::null_mut() @@ -236,9 +233,9 @@ pub(crate) fn project_parsed_identity_update( /// Deserializes a raw `IdentityUpdateTransition` (as carried by a DashConnect /// `dash-st:` QR) into its inspectable parts. /// -/// Accepts both normal tagged DPP state-transition bytes and Yappr's tagless -/// framing, where the positional bincode enum variant tag `6` has to be -/// prepended before deserialization. +/// Accepts both normal tagged DPP state-transition bytes and an identity update +/// serialized without the `StateTransition` variant tag (Yappr's framing); +/// trailing bytes are refused. /// /// Does NOT sign and does NOT broadcast — the caller validates the result and /// rebuilds the transition through the normal signing path. @@ -255,7 +252,11 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition( let bytes = slice::from_raw_parts(transition_bytes, transition_len); let transition = unwrap_result_or_return!(parse_identity_update_transition_bytes(bytes)); - *out = unwrap_result_or_return!(project_parsed_identity_update(&transition)); + *out = unwrap_result_or_return!(project_parsed_identity_update( + &transition.identity_id(), + transition.public_keys_to_add(), + transition.public_key_ids_to_disable(), + )); PlatformWalletFFIResult::ok() } @@ -555,27 +556,6 @@ mod tests { unsafe { platform_wallet_parse_identity_update_transition_free(&mut out) }; } - #[test] - fn parses_tagless_framing_by_prepending_the_variant_tag() { - let tagged = fixture_transition_bytes(); - let tagless = tagged[1..].to_vec(); - let mut out = ParsedIdentityUpdateFFI::default(); - - let result = unsafe { - platform_wallet_parse_identity_update_transition( - tagless.as_ptr(), - tagless.len(), - &mut out, - ) - }; - - assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(out.identity_id, [0x11; 32]); - assert_eq!(out.add_public_keys_count, 2); - - unsafe { platform_wallet_parse_identity_update_transition_free(&mut out) }; - } - #[test] fn rejects_malformed_identity_update_transition_bytes() { let bytes = [0xde, 0xad, 0xbe, 0xef]; @@ -652,7 +632,11 @@ mod tests { // `ParsedIdentityUpdateFFI` is a raw-pointer C struct with no `Debug`, // so the success case is rejected by hand rather than with `expect_err`. - let error = match project_parsed_identity_update(&transition) { + let error = match project_parsed_identity_update( + &transition.identity_id(), + transition.public_keys_to_add(), + transition.public_key_ids_to_disable(), + ) { Ok(_) => panic!("a document type with an interior NUL must not be narrowed"), Err(error) => error, }; 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 13090125ffa..df8fdcc00d9 100644 --- a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs +++ b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs @@ -1,10 +1,10 @@ -//! FFI parser for raw DPP state transitions handed to the wallet by a dApp -//! (DashConnect `dash-st:` links / QRs, DashPay Connect `sign`). +//! FFI projection of [`summarize_state_transition`] for raw DPP state +//! transitions handed to the wallet by a dApp (DashConnect `dash-st:` links / +//! QRs, DashPay Connect `sign`). //! -//! 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: +//! Decoding and deciding what the user has to see live in `platform-wallet` +//! ([`StateTransitionSummary`]); this module copies the summary into owned C +//! structs: //! //! - `Batch`: one row per batched transition (contract id, document type, //! action, and for token transitions the amount and recipient), @@ -13,60 +13,36 @@ //! - `IdentityCreditTransfer`: recipient and amount, //! - `DataContractCreate` / `DataContractUpdate`: contract id and document //! type names, -//! - everything else: the kind name only. +//! - everything else: `OTHER`, with the whole decoded transition in `details`. //! -//! 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. +//! **Completeness contract.** A batched row whose typed fields do not cover +//! every material field carries `complete == false` and renders the rest in +//! its `details`; a wallet must not approve such a row from the typed summary +//! alone. The `OTHER` kind likewise carries the whole decoded transition in +//! the common `details`. //! //! 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::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::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 platform_wallet::error::PlatformWalletError; +use platform_wallet::wallet::identity::network::{ + summarize_state_transition, BatchedTransitionSummary, BatchedTransitionTarget, + DataContractSummary, StateTransitionSummary, StateTransitionSummaryKind, +}; use crate::check_ptr; use crate::error::*; use crate::identity_update::{ platform_wallet_parse_identity_update_transition_free, project_parsed_identity_update, - ParsedIdentityUpdateFFI, IDENTITY_UPDATE_VARIANT_TAG, + ParsedIdentityUpdateFFI, }; use crate::unwrap_result_or_return; -/// Positional bincode variant tag of `StateTransition::Batch`. -pub(crate) const BATCH_VARIANT_TAG: u8 = 2; - /// `ParsedStateTransitionFFI::kind`: nothing was parsed (default state). pub const PARSED_STATE_TRANSITION_KIND_NONE: u8 = 0; /// `ParsedStateTransitionFFI::kind`: `identity_update` is populated. @@ -81,9 +57,8 @@ 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. +/// `ParsedStateTransitionFFI::kind`: a kind without a describer; the common +/// fields are populated, with the whole transition in `details`. pub const PARSED_STATE_TRANSITION_KIND_OTHER: u8 = 255; /// `ParsedBatchedTransitionFFI::family`: a document transition. @@ -91,35 +66,11 @@ 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 -/// to send tagless; `Batch` payloads have so far arrived properly tagged. -const TAGLESS_FRAMING_CANDIDATES: &[(u8, &str)] = &[ - (IDENTITY_UPDATE_VARIANT_TAG, "IdentityUpdate"), - (BATCH_VARIANT_TAG, "Batch"), -]; - -/// One transition inside a parsed `BatchTransition`. -/// -/// `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`]. -/// -/// `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. +/// One transition inside a parsed `BatchTransition`; the fields mirror +/// `BatchedTransitionSummary`. `action`, `document_type` (null for token rows) +/// and `details` are owned, NUL-terminated, and released by +/// [`platform_wallet_parse_state_transition_free`]. A row with +/// `complete == false` must not be approved without rendering `details`. #[repr(C)] pub struct ParsedBatchedTransitionFFI { /// `PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT` or `_TOKEN`. @@ -140,6 +91,16 @@ pub struct ParsedBatchedTransitionFFI { pub amount: u64, pub has_recipient: bool, pub recipient_id: [u8; 32], + /// Whether a direct purchase's token count is set. + pub has_token_count: bool, + /// Tokens bought by a `DirectPurchase`. + pub token_count: u64, + /// Whether the typed fields describe every material field. See the + /// struct doc. + pub complete: bool, + /// Rendering of the material fields the typed fields do not cover, when + /// `complete` is false; null otherwise. Owned, NUL-terminated. + pub details: *mut c_char, } impl Default for ParsedBatchedTransitionFFI { @@ -156,6 +117,10 @@ impl Default for ParsedBatchedTransitionFFI { amount: 0, has_recipient: false, recipient_id: [0u8; 32], + has_token_count: false, + token_count: 0, + complete: true, + details: ptr::null_mut(), } } } @@ -234,11 +199,19 @@ pub struct ParsedStateTransitionFFI { /// 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. + /// Percentage added to the processing fee; part of the signed bytes. + pub user_fee_increase: u16, + /// Whether the typed payload shows every material field + /// (`StateTransitionSummary::is_complete`); when false, render `details` + /// (or the rows' `details`) before approval. + pub complete: bool, + /// The decoded transition re-serialized, tagged: what a caller signs + /// after approval. pub serialized: *mut u8, pub serialized_len: usize, + /// `OTHER`: `Debug` dump of the whole transition; data contract create / + /// update: of the whole contract. Null otherwise. Owned, NUL-terminated. + pub details: *mut c_char, /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE`. pub identity_update: ParsedIdentityUpdateFFI, /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_BATCH`. @@ -257,8 +230,11 @@ impl Default for ParsedStateTransitionFFI { has_owner_id: false, owner_id: [0u8; 32], is_signed: false, + user_fee_increase: 0, + complete: false, serialized: ptr::null_mut(), serialized_len: 0, + details: ptr::null_mut(), identity_update: ParsedIdentityUpdateFFI::default(), batch: ParsedBatchFFI::default(), credit_transfer: ParsedCreditTransferFFI::default(), @@ -267,70 +243,12 @@ impl Default for ParsedStateTransitionFFI { } } -/// 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. -/// -/// 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<(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); - prefixed.push(*tag); - prefixed.extend_from_slice(bytes); - ( - Cow::Owned(prefixed), - format!("{name} variant tag prepended"), - ) - }); - let attempts: Vec<(Cow<'_, [u8]>, String)> = std::iter::once(as_is).chain(prepended).collect(); - - 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) => decoded.push((state_transition, payload.to_vec(), label)), - Err(error) => failures.push(format!("{label}: {error}")), - } - } - - 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(", ") - ), - )), - } +/// Bytes the summary could not decode. +pub(crate) fn deserialization_error(error: PlatformWalletError) -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + error.to_string(), + ) } /// A string that cannot cross the FFI as a C string is an error rather than @@ -349,6 +267,13 @@ fn owned_c_string(value: &str, what: &str) -> Result, + what: &str, +) -> Result, PlatformWalletFFIResult> { + value.map(|value| owned_c_string(value, what)).transpose() +} + unsafe fn free_c_string(ptr: &mut *mut c_char) { if !ptr.is_null() { drop(CString::from_raw(*ptr)); @@ -356,95 +281,65 @@ unsafe fn free_c_string(ptr: &mut *mut c_char) { } } -fn project_document_transition( - transition: &DocumentTransition, +fn project_batched_transition( + row: &BatchedTransitionSummary, ) -> 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", - )?; - - 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(), + let action = owned_c_string(&row.action, "Batched transition action")?; + let details = optional_c_string(row.details.as_deref(), "Batched transition details")?; + let mut out = ParsedBatchedTransitionFFI { + data_contract_id: row.data_contract_id.to_buffer(), + has_amount: row.amount.is_some(), + amount: row.amount.unwrap_or_default(), + has_recipient: row.recipient_id.is_some(), + recipient_id: row + .recipient_id + .map(|id| id.to_buffer()) + .unwrap_or_default(), + has_token_count: row.token_count.is_some(), + token_count: row.token_count.unwrap_or_default(), + complete: row.is_complete(), ..ParsedBatchedTransitionFFI::default() - }) -} - -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() - }) + match &row.target { + BatchedTransitionTarget::Document { + document_type, + document_id, + } => { + let document_type = + owned_c_string(document_type, "Batched document transition document type")?; + out.family = PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT; + out.document_type = document_type.into_raw(); + out.document_id = document_id.to_buffer(); + } + BatchedTransitionTarget::Token { + token_id, + token_contract_position, + } => { + out.family = PARSED_BATCHED_TRANSITION_FAMILY_TOKEN; + out.token_id = token_id.to_buffer(); + out.token_contract_position = *token_contract_position; + } + } + out.action = action.into_raw(); + out.details = details.map(CString::into_raw).unwrap_or(ptr::null_mut()); + Ok(out) } 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); + free_c_string(&mut row.details); } } fn project_parsed_batch( - batch: &BatchTransition, + owner_id: &Identifier, + transitions: &[BatchedTransitionSummary], ) -> 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 { + let mut rows: Vec = Vec::with_capacity(transitions.len()); + for transition in transitions { + match project_batched_transition(transition) { Ok(row) => rows.push(row), Err(error) => { // The caller never receives this struct, so nothing else will @@ -463,7 +358,7 @@ fn project_parsed_batch( }; Ok(ParsedBatchFFI { - owner_id: batch.owner_id().to_buffer(), + owner_id: owner_id.to_buffer(), transitions, transitions_count, }) @@ -479,14 +374,15 @@ unsafe fn free_parsed_batch(batch: &mut ParsedBatchFFI) { } fn project_parsed_data_contract( - contract: &dpp::data_contract::serialized_version::DataContractInSerializationFormat, + contract: &DataContractSummary, ) -> 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 names = contract + .document_type_names + .iter() + .map(|name| owned_c_string(name, "Data contract document type name")) + .collect::, _>>()?; let document_type_names_count = names.len(); let document_type_names = if document_type_names_count == 0 { @@ -497,8 +393,8 @@ fn project_parsed_data_contract( }; Ok(ParsedDataContractFFI { - contract_id: contract.id().to_buffer(), - owner_id: contract.owner_id().to_buffer(), + contract_id: contract.contract_id.to_buffer(), + owner_id: contract.owner_id.to_buffer(), document_type_names, document_type_names_count, }) @@ -518,121 +414,104 @@ unsafe fn free_parsed_data_contract(contract: &mut ParsedDataContractFFI) { *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. +/// Copies a summary into the C struct. Everything allocated is owned by the +/// result on success; on failure nothing is left allocated. fn project_parsed_state_transition( - transition: &StateTransition, - serialized: Vec, + summary: StateTransitionSummary, ) -> Result { - let mut out = ParsedStateTransitionFFI::default(); + // Kept as owned `CString`s until nothing below can fail, so an error + // return drops them. + let kind_name = owned_c_string(&summary.kind_name, "State transition kind name")?; + let details = match &summary.kind { + StateTransitionSummaryKind::Other { details } + | StateTransitionSummaryKind::DataContractCreate(DataContractSummary { details, .. }) + | StateTransitionSummaryKind::DataContractUpdate(DataContractSummary { details, .. }) => { + Some(owned_c_string(details, "State transition details")?) + } + _ => None, + }; - 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 - }) + // Each arm stores at most one payload, and only on success, so an error + // leaves nothing to free. + let mut out = ParsedStateTransitionFFI::default(); + out.kind = match &summary.kind { + StateTransitionSummaryKind::IdentityUpdate { + identity_id, + add_public_keys, + disable_public_key_ids, + } => { + out.identity_update = project_parsed_identity_update( + identity_id, + add_public_keys, + disable_public_key_ids, + )?; + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE } - StateTransition::Batch(batch) => project_parsed_batch(batch).map(|parsed| { - out.batch = parsed; + StateTransitionSummaryKind::Batch { + owner_id, + transitions, + } => { + out.batch = project_parsed_batch(owner_id, transitions)?; PARSED_STATE_TRANSITION_KIND_BATCH - }), - StateTransition::IdentityCreditTransfer(transfer) => { + } + StateTransitionSummaryKind::CreditTransfer { + identity_id, + recipient_id, + amount, + } => { out.credit_transfer = ParsedCreditTransferFFI { - identity_id: transfer.identity_id().to_buffer(), - recipient_id: transfer.recipient_id().to_buffer(), - amount: transfer.amount(), + identity_id: identity_id.to_buffer(), + recipient_id: recipient_id.to_buffer(), + amount: *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 - }) + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER } - StateTransition::DataContractUpdate(update) => { - project_parsed_data_contract(update.data_contract()).map(|parsed| { - out.data_contract = parsed; - PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE - }) + StateTransitionSummaryKind::DataContractCreate(contract) => { + out.data_contract = project_parsed_data_contract(contract)?; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE } - _ => 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); + StateTransitionSummaryKind::DataContractUpdate(contract) => { + out.data_contract = project_parsed_data_contract(contract)?; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE } + StateTransitionSummaryKind::Other { .. } => PARSED_STATE_TRANSITION_KIND_OTHER, }; - 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; + out.complete = summary.is_complete(); + out.kind_name = kind_name.into_raw(); + out.has_owner_id = summary.owner_id.is_some(); + out.owner_id = summary + .owner_id + .map(|id| id.to_buffer()) + .unwrap_or_default(); + out.is_signed = summary.is_signed; + out.user_fee_increase = summary.user_fee_increase; + out.serialized_len = summary.serialized.len(); + out.serialized = Box::into_raw(summary.serialized.into_boxed_slice()) as *mut u8; + out.details = details.map(CString::into_raw).unwrap_or(ptr::null_mut()); 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 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 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; `out.serialized` always holds the -/// tagged bytes that actually decoded. -/// -/// Does NOT sign and does NOT broadcast. +/// Decodes a raw DPP state transition (a DashConnect `dash-st:` link / QR or a +/// DashPay Connect `sign` request) and copies [`summarize_state_transition`]'s +/// summary into `out`. Accepts tagged bytes and an identity update or batch +/// serialized without the `StateTransition` variant tag; trailing bytes are +/// refused. Does NOT sign and does NOT broadcast. #[no_mangle] pub unsafe extern "C" fn platform_wallet_parse_state_transition( transition_bytes: *const u8, transition_len: usize, out: *mut ParsedStateTransitionFFI, ) -> PlatformWalletFFIResult { - check_ptr!(transition_bytes); check_ptr!(out); - *out = ParsedStateTransitionFFI::default(); + check_ptr!(transition_bytes); let bytes = slice::from_raw_parts(transition_bytes, transition_len); - let (transition, serialized) = unwrap_result_or_return!( - deserialize_transition_with_flexible_framing(bytes, TAGLESS_FRAMING_CANDIDATES) - ); + let summary = + unwrap_result_or_return!(summarize_state_transition(bytes).map_err(deserialization_error)); - *out = unwrap_result_or_return!(project_parsed_state_transition(&transition, serialized)); + *out = unwrap_result_or_return!(project_parsed_state_transition(summary)); PlatformWalletFFIResult::ok() } @@ -649,8 +528,11 @@ pub unsafe extern "C" fn platform_wallet_parse_state_transition_free( } let parsed = &mut *out; - free_parsed_state_transition_payloads(parsed); + 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); free_c_string(&mut parsed.kind_name); + free_c_string(&mut parsed.details); if !parsed.serialized.is_null() && parsed.serialized_len > 0 { drop(Box::from_raw(ptr::slice_from_raw_parts_mut( parsed.serialized, @@ -663,6 +545,11 @@ pub unsafe extern "C" fn platform_wallet_parse_state_transition_free( #[cfg(test)] mod tests { use super::*; + use dpp::state_transition::batch_transition::batched_transition::{ + DocumentTransition, TokenTransition, + }; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::StateTransition; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; @@ -824,13 +711,17 @@ mod tests { } fn credit_transfer_bytes() -> Vec { + credit_transfer_bytes_with_fee_increase(0) + } + + fn credit_transfer_bytes_with_fee_increase(user_fee_increase: u16) -> Vec { StateTransition::IdentityCreditTransfer( IdentityCreditTransferTransitionV0 { identity_id: Identifier::from([0x11; 32]), recipient_id: Identifier::from(RECIPIENT), amount: 1_000, nonce: 1, - user_fee_increase: 0, + user_fee_increase, signature_public_key_id: 0, signature: BinaryData::new(vec![]), } @@ -951,12 +842,20 @@ mod tests { assert_eq!(create.document_id, [0x0D; 32]); assert!(!create.has_amount); assert!(!create.has_recipient); + // The document data has no typed projection, so the row is + // incomplete and renders it. + assert!(!create.complete); + let create_details = unsafe { c_str(create.details) }; + assert!(create_details.contains("\"message\""), "{create_details}"); + assert!(create_details.contains("\"hi\""), "{create_details}"); 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); + assert!(transfer.complete); + assert!(transfer.details.is_null()); let token_transfer = &rows[2]; assert_eq!( @@ -978,6 +877,11 @@ mod tests { assert!(purchase.has_amount); assert_eq!(purchase.amount, 100_000_000); assert!(!purchase.has_recipient); + assert!(purchase.has_token_count); + assert_eq!(purchase.token_count, 100); + assert!(purchase.complete); + assert_eq!(out.user_fee_increase, 1); + assert!(!out.complete, "the create row renders its data"); // The unused payloads stay in their default state. assert!(out.identity_update.add_public_keys.is_null()); @@ -1003,24 +907,6 @@ mod tests { unsafe { platform_wallet_parse_state_transition_free(&mut out) }; } - #[test] - 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" - ); - let tagless = tagged[1..].to_vec(); - - let (result, mut out) = parse(&tagless); - - assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - 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_with_key_limits_and_bounds() { let bytes = identity_update_transition_bytes(); @@ -1119,6 +1005,8 @@ mod tests { .collect() }; assert_eq!(names, expected_names); + assert!(!out.complete); + assert!(unsafe { c_str(out.details) }.contains("niceDocument")); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; assert!(out.data_contract.document_type_names.is_null()); @@ -1168,11 +1056,55 @@ mod tests { assert!(out.has_owner_id); assert!(out.is_signed); assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + // The structured dump names the vote's material fields. + let details = unsafe { c_str(out.details) }; + assert!(details.contains("parentNameAndLabel"), "{details}"); + assert!(details.contains("Abstain"), "{details}"); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; assert!(out.kind_name.is_null()); + assert!(out.details.is_null()); } + /// An `OTHER` kind the wallet is likely to be asked to sign; shared with + /// the client suites as the `credit_withdrawal` fixture. + fn credit_withdrawal_bytes() -> Vec { + use dpp::identity::core_script::CoreScript; + use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; + use dpp::withdrawal::Pooling; + + StateTransition::IdentityCreditWithdrawal( + IdentityCreditWithdrawalTransitionV1 { + identity_id: Identifier::from([0x11; 32]), + amount: 123_456_789, + core_fee_per_byte: 7, + pooling: Pooling::Never, + output_script: Some(CoreScript::from_bytes(vec![0x76, 0xa9, 0x14, 0xAB])), + nonce: 3, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture withdrawal serializes") + } + + /// The user fee increase is part of the signed bytes and scales the + /// processing fee, so two otherwise identical transfers must not parse + /// to the same approval fields. + /// A token config update naming one action taker must not parse to the + /// same row as one naming another; the change item is rendered in + /// `details` and the row is marked incomplete. + /// Emergency action, price schedule and claim distribution type were + /// exposed by the old single-purchase parser's predecessor and must not + /// be lost: each renders in `details` with the row marked incomplete. + /// The tagged 2,340-byte contract create also decodes as a 47-byte + /// identity update when the IdentityUpdate tag is prepended; only the + /// framing that consumes every byte counts, so the contract parses. + /// Trailing bytes after a complete transition are refused, since the + /// summary would not show whatever they carry. /// 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 @@ -1196,6 +1128,8 @@ mod tests { hex::encode(credit_transfer_bytes()), hex::encode(contract_create), hex::encode(contract_update), + hex::encode(credit_transfer_bytes_with_fee_increase(65_535)), + hex::encode(credit_withdrawal_bytes()), ]; let expected = [ FIXTURE_IDENTITY_UPDATE_HEX, @@ -1203,6 +1137,8 @@ mod tests { FIXTURE_CREDIT_TRANSFER_HEX, FIXTURE_DATA_CONTRACT_CREATE_HEX, FIXTURE_DATA_CONTRACT_UPDATE_HEX, + FIXTURE_CREDIT_TRANSFER_MAX_FEE_HEX, + FIXTURE_CREDIT_WITHDRAWAL_HEX, ] .map(|hex| hex.trim().to_string()); assert_eq!( @@ -1231,77 +1167,8 @@ mod tests { 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 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!( - error.code, - PlatformWalletFFIResultCode::ErrorDeserialization - ); - let message = unsafe { CStr::from_ptr(error.message) }.to_str().unwrap(); - assert!(message.contains("Ambiguous"), "{message}"); - unsafe { platform_wallet_ffi_result_free(&mut error) }; - } + const FIXTURE_CREDIT_TRANSFER_MAX_FEE_HEX: &str = client_fixture!("credit_transfer_max_fee"); + const FIXTURE_CREDIT_WITHDRAWAL_HEX: &str = client_fixture!("credit_withdrawal"); #[test] fn rejects_malformed_state_transition_bytes() { diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 5d62f66db0e..2810606f8c0 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -93,6 +93,8 @@ required-features = ["shielded"] [dev-dependencies] tempfile = "3.27.0" +# `tests::fixtures` for the state transition summary tests. +dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } # In-process Signer for the manual testnet # verification example (`examples/dpns_marketplace_testnet.rs`). simple-signer = { path = "../simple-signer", features = ["state-transitions"] } 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 75ed2a513e7..1b66ae49938 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -44,6 +44,7 @@ mod key_selection; mod loading; mod register_from_addresses; mod registration; +mod state_transition_summary; mod top_up; mod top_up_from_addresses; mod transfer; @@ -107,6 +108,11 @@ pub use identity_handle::{ IdentityWallet, CONNECT_SUB_FEATURE_APP_ENCRYPTION, CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; +pub use state_transition_summary::{ + decode_state_transition, summarize_state_transition, BatchedTransitionSummary, + BatchedTransitionTarget, DataContractSummary, StateTransitionSummary, + StateTransitionSummaryKind, +}; // Helpers declared on `identity_handle.rs` that siblings reach // through `use super::*;`. Sibling-only — re-exporting here just diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs new file mode 100644 index 00000000000..85d9af76c1a --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -0,0 +1,1011 @@ +//! What a state transition a dApp hands the wallet asks for, before the user +//! approves it (DashConnect `dash-st:` links / QRs, DashPay Connect `sign`). +//! +//! The wallet must never sign opaque bytes a web page hands it without showing +//! the user what they are. [`summarize_state_transition`] decodes any kind with +//! rs-dpp's exact untrusted decoder and returns a [`StateTransitionSummary`]: +//! the common fields every kind has, a typed summary for the kinds the approval +//! sheet describes, and a rendering of every material field the typed summary +//! does not cover. Nothing is refused on kind. +//! +//! This module does not sign and does not broadcast. + +use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dpp::identity::KeyID; +use dpp::prelude::{Identifier, UserFeeIncrease}; +use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +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_transition::DocumentTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionTypeGetter; +use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; +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, DocumentTransition, TokenTransition, +}; +use dpp::state_transition::batch_transition::document_base_transition::v1::v1_methods::DocumentBaseTransitionV1Methods; +use dpp::state_transition::batch_transition::document_base_transition::v2::v2_methods::DocumentBaseTransitionV2Methods; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +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_claim_transition::v0::v0_methods::TokenClaimTransitionV0Methods; +use dpp::state_transition::batch_transition::token_config_update_transition::v0::v0_methods::TokenConfigUpdateTransitionV0Methods; +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_emergency_action_transition::v0::v0_methods::TokenEmergencyActionTransitionV0Methods; +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_set_price_for_direct_purchase_transition::v0::v0_methods::TokenSetPriceForDirectPurchaseTransitionV0Methods; +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::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::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::{StateTransition, StateTransitionOwned, StateTransitionType}; + +use crate::error::PlatformWalletError; + +/// Kinds some dApps send serialized on their own, without the +/// `StateTransition` variant tag in front (Yappr sends identity updates that +/// way, from wasm-dpp2's inner `toBytes()`). +pub const UNTAGGED_STATE_TRANSITION_KINDS: &[StateTransitionType] = &[ + StateTransitionType::IdentityUpdate, + StateTransitionType::Batch, +]; + +/// What a state transition asks for. +#[derive(Debug, Clone, PartialEq)] +pub struct StateTransitionSummary { + /// `StateTransition::name()`, e.g. `IdentityUpdate`, + /// `DocumentsBatch([Create, TokenTransfer])`, `MasternodeVote`. + pub kind_name: String, + /// The identity the transition acts for; `None` for the asset-lock-funded + /// and shielded kinds. + pub owner_id: Option, + /// Whether the transition already carries a non-empty signature. A `sign` + /// request must arrive unsigned. + pub is_signed: bool, + /// Percentage added to the processing fee Platform charges (`65535` is + /// about 656 times the base). Part of the signed bytes, so an approval + /// sheet must show a non-zero value. + pub user_fee_increase: UserFeeIncrease, + /// The bytes that decoded, always tagged. These, not the input, are what + /// a caller signs after approval. + pub serialized: Vec, + pub kind: StateTransitionSummaryKind, +} + +impl StateTransitionSummary { + /// Whether the typed summary shows every material field. When it does not, + /// a wallet must render the `details` it carries before approval. + pub fn is_complete(&self) -> bool { + match &self.kind { + StateTransitionSummaryKind::Batch { transitions, .. } => transitions + .iter() + .all(BatchedTransitionSummary::is_complete), + StateTransitionSummaryKind::IdentityUpdate { .. } + | StateTransitionSummaryKind::CreditTransfer { .. } => true, + StateTransitionSummaryKind::DataContractCreate(_) + | StateTransitionSummaryKind::DataContractUpdate(_) + | StateTransitionSummaryKind::Other { .. } => false, + } + } +} + +/// The typed part of a [`StateTransitionSummary`]. +#[derive(Debug, Clone, PartialEq)] +pub enum StateTransitionSummaryKind { + IdentityUpdate { + identity_id: Identifier, + add_public_keys: Vec, + disable_public_key_ids: Vec, + }, + Batch { + owner_id: Identifier, + transitions: Vec, + }, + CreditTransfer { + identity_id: Identifier, + recipient_id: Identifier, + amount: u64, + }, + DataContractCreate(DataContractSummary), + DataContractUpdate(DataContractSummary), + /// A kind without a describer. `details` is the whole decoded transition. + Other { + details: String, + }, +} + +/// The contract a data contract create or update registers. +#[derive(Debug, Clone, PartialEq)] +pub struct DataContractSummary { + pub contract_id: Identifier, + pub owner_id: Identifier, + /// Document type names, in the contract's order. + pub document_type_names: Vec, + /// The whole contract (schemas, tokens and their distribution rules, + /// groups, keywords): everything the names above do not show, and what + /// the registration fee is charged on. + pub details: String, +} + +/// One transition inside a batch. +#[derive(Debug, Clone, PartialEq)] +pub struct BatchedTransitionSummary { + pub data_contract_id: Identifier, + /// The rs-dpp action name (`Create`, `Transfer`, `DirectPurchase`, ...). + pub action: String, + pub target: BatchedTransitionTarget, + /// Credits or tokens moved: a document purchase price or update-price + /// value, a token transfer / mint / burn amount, or a direct purchase's + /// total agreed price. + pub amount: Option, + /// The identity on the other side, read against `action`: 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. + pub recipient_id: Option, + /// Tokens bought by a direct purchase. + pub token_count: Option, + /// The material fields `amount` / `recipient_id` / `token_count` do not + /// cover, one per line (document data, a config change item, a price + /// schedule, notes, group action info). `None` when there are none. + pub details: Option, +} + +impl BatchedTransitionSummary { + /// Whether `amount` / `recipient_id` / `token_count` show every material + /// field; when not, `details` must be rendered before approval. + pub fn is_complete(&self) -> bool { + self.details.is_none() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BatchedTransitionTarget { + Document { + document_type: String, + document_id: Identifier, + }, + Token { + token_id: Identifier, + token_contract_position: u16, + }, +} + +/// Decodes `bytes` and summarizes what they ask for. +/// +/// Tagged `StateTransition` bytes and the untagged kinds in +/// [`UNTAGGED_STATE_TRANSITION_KINDS`] are accepted. Every framing is tried +/// with the exact decoder, which refuses leftover bytes, and exactly one must +/// decode: a payload that decodes under two is refused rather than described +/// under whichever was tried first, since the caller signs what the user was +/// shown. +pub fn summarize_state_transition( + bytes: &[u8], +) -> Result { + let (transition, serialized) = decode_state_transition(bytes, UNTAGGED_STATE_TRANSITION_KINDS)?; + Ok(summarize(&transition, serialized)) +} + +/// Decodes `bytes` as a tagged `StateTransition` or as one of `untagged_kinds` +/// serialized on its own. Returns the transition and its tagged bytes. +pub fn decode_state_transition( + bytes: &[u8], + untagged_kinds: &[StateTransitionType], +) -> Result<(StateTransition, Vec), PlatformWalletError> { + let mut decoded: Vec<(StateTransition, String)> = Vec::new(); + let mut failures: Vec = Vec::new(); + + match StateTransition::deserialize_from_bytes_untrusted_exact(bytes) { + Ok(transition) => decoded.push((transition, "tagged".to_string())), + Err(error) => failures.push(format!("tagged: {error}")), + } + for kind in untagged_kinds { + match StateTransition::deserialize_untagged_untrusted_exact(*kind, bytes) { + Ok(transition) => decoded.push((transition, format!("untagged {kind}"))), + Err(error) => failures.push(format!("untagged {kind}: {error}")), + } + } + + match decoded.len() { + 0 => Err(PlatformWalletError::InvalidParameter(format!( + "Failed to deserialize state transition in any supported framing ({})", + failures.join("; ") + ))), + 1 => { + let (transition, _) = decoded.pop().expect("one decoded framing"); + let serialized = transition.serialize_to_bytes().map_err(|error| { + PlatformWalletError::InvalidParameter(format!( + "Decoded state transition does not re-serialize: {error}" + )) + })?; + Ok((transition, serialized)) + } + _ => Err(PlatformWalletError::InvalidParameter(format!( + "Ambiguous state transition framing: the bytes decode as {}; refusing to guess \ + which one the sender meant", + decoded + .iter() + .map(|(transition, framing)| format!("{} ({framing})", transition.name())) + .collect::>() + .join(", ") + ))), + } +} + +fn summarize(transition: &StateTransition, serialized: Vec) -> StateTransitionSummary { + let kind = match transition { + StateTransition::IdentityUpdate(update) => StateTransitionSummaryKind::IdentityUpdate { + identity_id: update.identity_id(), + add_public_keys: update.public_keys_to_add().to_vec(), + disable_public_key_ids: update.public_key_ids_to_disable().to_vec(), + }, + StateTransition::Batch(batch) => StateTransitionSummaryKind::Batch { + owner_id: batch.owner_id(), + transitions: summarize_batch(batch), + }, + StateTransition::IdentityCreditTransfer(transfer) => { + StateTransitionSummaryKind::CreditTransfer { + identity_id: transfer.identity_id(), + recipient_id: transfer.recipient_id(), + amount: transfer.amount(), + } + } + StateTransition::DataContractCreate(create) => { + StateTransitionSummaryKind::DataContractCreate(summarize_contract( + create.data_contract(), + )) + } + StateTransition::DataContractUpdate(update) => { + StateTransitionSummaryKind::DataContractUpdate(summarize_contract( + update.data_contract(), + )) + } + other => StateTransitionSummaryKind::Other { + details: format!("{other:#?}"), + }, + }; + + StateTransitionSummary { + kind_name: transition.name(), + owner_id: transition.owner_id(), + is_signed: transition.signature().is_some_and(|sig| !sig.is_empty()), + user_fee_increase: transition.user_fee_increase(), + serialized, + kind, + } +} + +fn summarize_contract(contract: &DataContractInSerializationFormat) -> DataContractSummary { + DataContractSummary { + contract_id: contract.id(), + owner_id: contract.owner_id(), + document_type_names: contract.document_schemas().keys().cloned().collect(), + details: format!("{contract:#?}"), + } +} + +fn summarize_batch(batch: &BatchTransition) -> Vec { + batch + .transitions_iter() + .map(|transition| match transition { + BatchedTransitionRef::Document(document) => summarize_document_transition(document), + BatchedTransitionRef::Token(token) => summarize_token_transition(token), + }) + .collect() +} + +fn join_details(lines: Vec) -> Option { + (!lines.is_empty()).then(|| lines.join("\n")) +} + +fn summarize_document_transition(transition: &DocumentTransition) -> BatchedTransitionSummary { + let (amount, recipient_id) = 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 mut lines = Vec::new(); + if let Some(data) = transition.data() { + lines.push(format!("data: {data:#?}")); + } + if let DocumentTransition::Create(create) = transition { + if let Some((index_name, credits)) = create.prefunded_voting_balance() { + lines.push(format!( + "prefunded voting balance: {credits} credits for index {index_name}" + )); + } + } + let base = transition.base(); + if let Some(payment) = base.token_payment_info() { + lines.push(format!("token payment: {payment:?}")); + } + if let Some(fees) = base.action_fee_agreement() { + lines.push(format!("action fees: {fees:?}")); + } + + BatchedTransitionSummary { + data_contract_id: transition.data_contract_id(), + action: format!("{:?}", transition.action_type()), + target: BatchedTransitionTarget::Document { + document_type: transition.document_type_name().clone(), + document_id: transition.get_id(), + }, + amount, + recipient_id, + token_count: None, + details: join_details(lines), + } +} + +fn summarize_token_transition(transition: &TokenTransition) -> BatchedTransitionSummary { + let mut lines = Vec::new(); + let (amount, recipient_id, token_count, public_note) = match transition { + TokenTransition::Transfer(t) => { + if t.shared_encrypted_note().is_some() { + lines.push("shared encrypted note: present".to_string()); + } + if t.private_encrypted_note().is_some() { + lines.push("private encrypted note: present".to_string()); + } + ( + Some(t.amount()), + Some(t.recipient_id()), + None, + t.public_note(), + ) + } + TokenTransition::Mint(t) => { + if t.issued_to_identity_id().is_none() { + lines.push("issued to: the token's default destination".to_string()); + } + ( + Some(t.amount()), + t.issued_to_identity_id(), + None, + t.public_note(), + ) + } + TokenTransition::Burn(t) => (Some(t.burn_amount()), None, None, t.public_note()), + TokenTransition::Freeze(t) => (None, Some(t.frozen_identity_id()), None, t.public_note()), + TokenTransition::Unfreeze(t) => (None, Some(t.frozen_identity_id()), None, t.public_note()), + TokenTransition::DestroyFrozenFunds(t) => { + (None, Some(t.frozen_identity_id()), None, t.public_note()) + } + TokenTransition::DirectPurchase(t) => ( + Some(t.total_agreed_price()), + None, + Some(t.token_count()), + None, + ), + TokenTransition::Claim(t) => { + lines.push(format!("distribution type: {}", t.distribution_type())); + (None, None, None, t.public_note()) + } + TokenTransition::EmergencyAction(t) => { + lines.push(format!("emergency action: {:?}", t.emergency_action())); + (None, None, None, t.public_note()) + } + TokenTransition::ConfigUpdate(t) => { + lines.push(format!( + "config update: {}", + t.update_token_configuration_item() + )); + (None, None, None, t.public_note()) + } + TokenTransition::SetPriceForDirectPurchase(t) => { + match t.price() { + Some(schedule) => lines.push(format!("price schedule: {schedule}")), + None => lines.push("price schedule: removed (not for sale)".to_string()), + } + (None, None, None, t.public_note()) + } + }; + // Debug-quoted so a note cannot forge further lines or carry a NUL. + if let Some(note) = public_note { + lines.push(format!("public note: {note:?}")); + } + let base = transition.base(); + if let Some(group) = base.using_group_info() { + lines.push(format!( + "group action: position {}, action id {}, {}", + group.group_contract_position, + group.action_id, + if group.action_is_proposer { + "proposing" + } else { + "signing an existing proposal" + } + )); + } + + BatchedTransitionSummary { + data_contract_id: transition.data_contract_id(), + action: transition.action_type().to_string(), + target: BatchedTransitionTarget::Token { + token_id: transition.token_id(), + token_contract_position: base.token_contract_position(), + }, + amount, + recipient_id, + token_count, + details: join_details(lines), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::document_type::action_fees::agreement::v0::DocumentActionFeeAgreementV0; + use dpp::state_transition::batch_transition::document_base_transition::v1::DocumentBaseTransitionV1; + use dpp::state_transition::batch_transition::document_base_transition::v2::DocumentBaseTransitionV2; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use dpp::tokens::token_payment_info::v0::TokenPaymentInfoV0; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; + use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + use dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; + use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; + use dpp::identity::core_script::CoreScript; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::{platform_value, BinaryData}; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::{ + BatchedTransition, DocumentTransferTransition, + }; + 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::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use dpp::state_transition::batch_transition::token_claim_transition::v0::TokenClaimTransitionV0; + use dpp::state_transition::batch_transition::token_config_update_transition::v0::TokenConfigUpdateTransitionV0; + use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::TokenDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::token_emergency_action_transition::v0::TokenEmergencyActionTransitionV0; + use dpp::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::v0::TokenSetPriceForDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::{ + BatchTransitionV1, DocumentCreateTransition, TokenClaimTransition, + TokenConfigUpdateTransition, TokenDirectPurchaseTransition, + TokenEmergencyActionTransition, TokenSetPriceForDirectPurchaseTransition, + TokenTransferTransition, + }; + use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; + use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; + use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; + use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; + use dpp::tokens::emergency_action::TokenEmergencyAction; + use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; + use dpp::withdrawal::Pooling; + use std::collections::BTreeMap; + + 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 token_base() -> TokenBaseTransition { + TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 4, + token_contract_position: 3, + data_contract_id: Identifier::from(CONTRACT), + token_id: Identifier::from(TOKEN), + using_group_info: 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 batch(transitions: Vec) -> StateTransition { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from(OWNER), + transitions, + user_fee_increase: 1, + signature_public_key_id: 2, + signature: BinaryData::new(vec![0x88; 65]), + })) + } + + fn credit_transfer(user_fee_increase: u16) -> StateTransition { + IdentityCreditTransferTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + nonce: 1, + user_fee_increase, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into() + } + + fn summarize_bytes(transition: &StateTransition) -> StateTransitionSummary { + summarize_state_transition(&transition.serialize_to_bytes().unwrap()).expect("summarizes") + } + + #[test] + fn a_mixed_batch_summarizes_one_row_per_transition() { + let transition = batch(vec![ + BatchedTransition::Document(DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base("post"), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), platform_value!("hi"))]), + prefunded_voting_balance: None, + }, + ))), + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base: document_base("profile"), + revision: 2, + recipient_owner_id: Identifier::from(RECIPIENT), + }), + )), + 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, + }, + ))), + BatchedTransition::Token(TokenTransition::DirectPurchase( + TokenDirectPurchaseTransition::V0(TokenDirectPurchaseTransitionV0 { + base: token_base(), + token_count: 100, + total_agreed_price: 100_000_000, + }), + )), + ]); + let summary = summarize_bytes(&transition); + + assert_eq!( + summary.kind_name, + "DocumentsBatch([Create, Transfer, TokenTransfer, TokenDirectPurchase])" + ); + assert_eq!(summary.owner_id, Some(Identifier::from(OWNER))); + assert!(summary.is_signed); + assert_eq!(summary.user_fee_increase, 1); + assert_eq!(summary.serialized, transition.serialize_to_bytes().unwrap()); + // The document data has no typed projection. + assert!(!summary.is_complete()); + + let StateTransitionSummaryKind::Batch { + owner_id, + transitions, + } = summary.kind + else { + panic!("expected a batch"); + }; + assert_eq!(owner_id, Identifier::from(OWNER)); + let [create, transfer, token_transfer, purchase] = transitions.as_slice() else { + panic!("expected four rows"); + }; + + assert_eq!(create.action, "Create"); + assert_eq!( + create.target, + BatchedTransitionTarget::Document { + document_type: "post".to_string(), + document_id: Identifier::from([0x0D; 32]), + } + ); + let create_details = create.details.as_deref().expect("data is rendered"); + assert!(create_details.contains("\"message\""), "{create_details}"); + + assert_eq!(transfer.action, "Transfer"); + assert_eq!(transfer.recipient_id, Some(Identifier::from(RECIPIENT))); + assert_eq!(transfer.details, None); + + assert_eq!(token_transfer.action, "Transfer"); + assert_eq!( + token_transfer.target, + BatchedTransitionTarget::Token { + token_id: Identifier::from(TOKEN), + token_contract_position: 3, + } + ); + assert_eq!(token_transfer.amount, Some(250)); + assert_eq!( + token_transfer.recipient_id, + Some(Identifier::from(RECIPIENT)) + ); + + assert_eq!(purchase.action, "DirectPurchase"); + assert_eq!(purchase.amount, Some(100_000_000)); + assert_eq!(purchase.token_count, Some(100)); + assert_eq!(purchase.details, None); + } + + #[test] + fn an_identity_update_carries_every_added_key_with_its_limits() { + let transition: StateTransition = IdentityUpdateTransitionV0 { + signature: BinaryData::new(vec![0x99; 65]), + signature_public_key_id: 3, + 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![0xbb; 65]), + contract_bounds: None, + 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, + } + .into(); + let summary = summarize_bytes(&transition); + + assert_eq!(summary.kind_name, "IdentityUpdate"); + assert!(summary.is_complete()); + let StateTransitionSummaryKind::IdentityUpdate { + identity_id, + add_public_keys, + disable_public_key_ids, + } = summary.kind + else { + panic!("expected an identity update"); + }; + assert_eq!(identity_id, Identifier::from([0x11; 32])); + assert_eq!(disable_public_key_ids, vec![4, 8]); + assert_eq!(add_public_keys[0].total_budget(), Some(10_000_000_000)); + assert_eq!(add_public_keys[0].expires_at(), Some(1_800_000_000_000)); + } + + /// Identity updates arrive untagged from some dApps; the summary carries + /// the tagged bytes, which are what the wallet signs. + #[test] + fn an_untagged_identity_update_is_summarized_with_tagged_bytes() { + let transition: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + revision: 1, + nonce: 1, + disable_public_keys: vec![3], + ..Default::default() + } + .into(); + let tagged = transition.serialize_to_bytes().unwrap(); + let StateTransition::IdentityUpdate(inner) = &transition else { + unreachable!() + }; + let untagged = inner.serialize_to_bytes().unwrap(); + assert_ne!(untagged, tagged); + + let summary = summarize_state_transition(&untagged).expect("summarizes"); + assert_eq!(summary.kind_name, "IdentityUpdate"); + assert_eq!(summary.serialized, tagged); + } + + #[test] + fn a_credit_transfer_summary() { + let summary = summarize_bytes(&credit_transfer(0)); + assert_eq!( + summary.kind, + StateTransitionSummaryKind::CreditTransfer { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + } + ); + assert!(!summary.is_signed); + } + + /// The fee multiplier is part of the signed bytes and scales the + /// processing fee, so two otherwise identical transfers must not summarize + /// to the same thing. + #[test] + fn the_user_fee_increase_is_part_of_the_summary() { + let plain = summarize_bytes(&credit_transfer(0)); + let maxed = summarize_bytes(&credit_transfer(u16::MAX)); + assert_eq!(plain.kind, maxed.kind); + assert_eq!(plain.user_fee_increase, 0); + assert_eq!(maxed.user_fee_increase, u16::MAX); + } + + /// Two config updates granting manual minting to different takers must not + /// summarize the same; neither is complete from the typed fields alone. + #[test] + fn token_details_cover_config_update_emergency_price_and_claim() { + let config_update = |takers: AuthorizedActionTakers| { + BatchedTransition::Token(TokenTransition::ConfigUpdate( + TokenConfigUpdateTransition::V0(TokenConfigUpdateTransitionV0 { + base: token_base(), + update_token_configuration_item: TokenConfigurationChangeItem::ManualMinting( + takers, + ), + public_note: Some("grant".to_string()), + }), + )) + }; + let summary = summarize_bytes(&batch(vec![ + config_update(AuthorizedActionTakers::ContractOwner), + config_update(AuthorizedActionTakers::Identity(Identifier::from( + RECIPIENT, + ))), + BatchedTransition::Token(TokenTransition::EmergencyAction( + TokenEmergencyActionTransition::V0(TokenEmergencyActionTransitionV0 { + base: token_base(), + emergency_action: TokenEmergencyAction::Pause, + public_note: None, + }), + )), + BatchedTransition::Token(TokenTransition::SetPriceForDirectPurchase( + TokenSetPriceForDirectPurchaseTransition::V0( + TokenSetPriceForDirectPurchaseTransitionV0 { + base: token_base(), + price: Some(TokenPricingSchedule::SinglePrice(4_200)), + public_note: None, + }, + ), + )), + BatchedTransition::Token(TokenTransition::Claim(TokenClaimTransition::V0( + TokenClaimTransitionV0 { + base: token_base(), + distribution_type: TokenDistributionType::Perpetual, + public_note: None, + }, + ))), + ])); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + let details: Vec = transitions + .into_iter() + .map(|row| row.details.expect("every row renders details")) + .collect(); + + assert_ne!(details[0], details[1]); + assert!(details[0].contains("Manual Minting"), "{}", details[0]); + assert!(details[0].contains("ContractOwner"), "{}", details[0]); + assert!( + details[0].contains("public note: \"grant\""), + "{}", + details[0] + ); + assert!( + details[1].contains(&format!("{}", Identifier::from(RECIPIENT))), + "{}", + details[1] + ); + assert!( + details[2].contains("emergency action: Pause"), + "{}", + details[2] + ); + assert!(details[3].contains("SinglePrice: 4200"), "{}", details[3]); + assert!( + details[4].contains("distribution type: Perpetual"), + "{}", + details[4] + ); + } + + #[test] + fn a_kind_without_a_describer_carries_the_whole_transition() { + let transition: StateTransition = IdentityCreditWithdrawalTransitionV1 { + identity_id: Identifier::from([0x11; 32]), + amount: 123_456_789, + core_fee_per_byte: 7, + pooling: Pooling::Never, + output_script: Some(CoreScript::from_bytes(vec![0x76, 0xa9, 0x14, 0xAB])), + nonce: 3, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(); + let summary = summarize_bytes(&transition); + + assert_eq!(summary.kind_name, "IdentityCreditWithdrawal"); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::Other { details } = summary.kind else { + panic!("expected other"); + }; + assert!(details.contains("amount: 123456789"), "{details}"); + assert!(details.contains("Never"), "{details}"); + assert!(details.contains("output_script"), "{details}"); + } + + #[test] + fn trailing_bytes_and_garbage_are_refused() { + let mut padded = credit_transfer(0).serialize_to_bytes().unwrap(); + padded.push(0); + assert!(matches!( + summarize_state_transition(&padded), + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("left over") + )); + assert!(summarize_state_transition(&[0xde, 0xad, 0xbe, 0xef]).is_err()); + } + + /// Tagged bytes of every described kind, and the untagged kinds' own + /// bytes, decode under exactly one framing. + #[test] + fn every_described_kind_decodes_under_one_framing() { + for transition in [ + credit_transfer(0), + batch(vec![]), + IdentityUpdateTransitionV0::default().into(), + ] { + let tagged = transition.serialize_to_bytes().unwrap(); + let (decoded, serialized) = + decode_state_transition(&tagged, UNTAGGED_STATE_TRANSITION_KINDS) + .expect("one framing"); + assert_eq!(decoded, transition); + assert_eq!(serialized, tagged); + } + } + + /// Asking for the same untagged kind twice makes one payload decode under + /// two framings, which is refused. + #[test] + fn ambiguous_framing_is_refused() { + let transition: StateTransition = IdentityUpdateTransitionV0::default().into(); + let StateTransition::IdentityUpdate(inner) = &transition else { + unreachable!() + }; + let untagged = inner.serialize_to_bytes().unwrap(); + let result = decode_state_transition( + &untagged, + &[ + StateTransitionType::IdentityUpdate, + StateTransitionType::IdentityUpdate, + ], + ); + assert!(matches!( + result, + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("Ambiguous") + )); + } + + /// A transfer or delete names no amount of its own, but its base can + /// commit the owner to a token cost (V1) or to action fees (V2); the row + /// must render them and not claim to be complete. + #[test] + fn document_rows_render_token_payment_and_action_fees() { + let payment = TokenPaymentInfoV0 { + payment_token_contract_id: None, + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(5_000), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + } + .into(); + let transfer = |base: DocumentBaseTransition| { + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base, + revision: 2, + recipient_owner_id: Identifier::from(RECIPIENT), + }), + )) + }; + let summary = summarize_bytes(&batch(vec![ + transfer(DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([0x0D; 32]), + identity_contract_nonce: 1, + document_type_name: "card".to_string(), + data_contract_id: Identifier::from(CONTRACT), + token_payment_info: Some(payment), + })), + transfer(DocumentBaseTransition::V2(DocumentBaseTransitionV2 { + id: Identifier::from([0x0D; 32]), + identity_contract_nonce: 2, + document_type_name: "card".to_string(), + data_contract_id: Identifier::from(CONTRACT), + token_payment_info: None, + action_fee_agreement: Some( + DocumentActionFeeAgreementV0 { + owner: 700, + moderators: 300, + fee_multiplier: None, + } + .into(), + ), + })), + ])); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + let token = transitions[0] + .details + .as_deref() + .expect("token payment rendered"); + assert!(token.contains("token payment:"), "{token}"); + assert!(token.contains("5000"), "{token}"); + let fees = transitions[1] + .details + .as_deref() + .expect("action fees rendered"); + assert!(fees.contains("action fees:"), "{fees}"); + assert!(fees.contains("700") && fees.contains("300"), "{fees}"); + } + + /// Tokens, groups and schemas are what a contract registration asks for + /// and pays on; the names alone do not show them. + #[test] + fn a_data_contract_create_is_not_complete_from_its_names() { + let created = get_data_contract_fixture( + Some(Identifier::from(OWNER)), + 1, + PlatformVersion::latest().protocol_version, + ); + let contract = DataContractInSerializationFormat::try_from_platform_versioned( + created.data_contract(), + PlatformVersion::latest(), + ) + .expect("serialization format"); + let transition: StateTransition = DataContractCreateTransitionV0 { + data_contract: contract, + identity_nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(); + let summary = summarize_bytes(&transition); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::DataContractCreate(contract) = summary.kind else { + panic!("expected a contract create"); + }; + assert_eq!(contract.owner_id, Identifier::from(OWNER)); + assert!( + contract.details.contains("niceDocument"), + "{}", + contract.details + ); + } + + /// A public note is quoted, so a newline cannot forge another line and a + /// NUL cannot break the C projection. + #[test] + fn public_notes_are_quoted() { + let summary = summarize_bytes(&batch(vec![BatchedTransition::Token( + TokenTransition::Transfer(TokenTransferTransition::V0(TokenTransferTransitionV0 { + base: token_base(), + amount: 1, + recipient_id: Identifier::from(RECIPIENT), + public_note: Some("hi\nrecipient: someone else\0".to_string()), + shared_encrypted_note: None, + private_encrypted_note: None, + })), + )])); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + let details = transitions[0].details.as_deref().expect("note rendered"); + assert_eq!(details.lines().count(), 1, "{details}"); + assert!(!details.contains('\0'), "{details}"); + } +} 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 66270ba5079..9806b257692 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -332,114 +332,3 @@ 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()); - } -} diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index fd722ca80ec..9b5ef3b4176 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -508,12 +508,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive 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)", - ); + // The FFI refuses a purpose other than 0, 1 or 2. + if purpose < 0 { + throw_sdk_exception(env, 1, "purpose must be non-negative"); return ptr::null_mut(); } if resolver_handle == 0 { @@ -1229,3 +1226,39 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_update .unwrap_or(ptr::null_mut()) }) } + +#[cfg(test)] +mod tests { + use platform_wallet_ffi::derive_connect_key::{ + CONNECT_KEY_PURPOSE_DECRYPTION, CONNECT_KEY_PURPOSE_ENCRYPTION, + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + }; + + const PLATFORM_WALLET_MANAGER_KT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt" + )); + + /// Kotlin's `ConnectSubFeature` / `ConnectKeyPurpose` literals are the + /// FFI's constants; a drift would derive keys at paths nothing looks for. + #[test] + fn kotlin_connect_key_constants_match_the_ffi() { + for (entry, value) in [ + ( + "SESSION_AUTHENTICATION", + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + ), + ("APP_ENCRYPTION", CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION), + ("ENCRYPTION", CONNECT_KEY_PURPOSE_ENCRYPTION), + ("DECRYPTION", CONNECT_KEY_PURPOSE_DECRYPTION), + ] { + let needle = format!("{entry}({value}),"); + assert!( + PLATFORM_WALLET_MANAGER_KT + .lines() + .any(|line| line.trim() == needle), + "PlatformWalletManager.kt no longer declares `{needle}`" + ); + } + } +} diff --git a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs index a8a42d1f442..2f5d678437d 100644 --- a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs +++ b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs @@ -21,10 +21,15 @@ //! //! ```text //! u8 kind (PARSED_STATE_TRANSITION_KIND_*) -//! u16 kind_name_len, u8[kind_name_len] kind_name (UTF-8) +//! u32 kind_name_len, u8[kind_name_len] kind_name (UTF-8) //! u8 has_owner_id; if 1: u8[32] owner_id //! u8 is_signed +//! u16 user_fee_increase (percentage added to the processing fee) +//! u8 complete (0 = render details / the rows' details before approval) //! u32 serialized_len, u8[serialized_len] serialized (tagged DPP bytes) +//! u32 details_len, u8[details_len] details (UTF-8; the Debug dump of the +//! whole transition for kind 255, of the contract for kinds 4 / 5, +//! empty otherwise) //! kind 1 (IdentityUpdate): //! u8[32] identity_id //! u32 add_count; repeat add_count times (same field order as @@ -44,6 +49,11 @@ //! 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 +//! u8 has_token_count; if 1: u64 token_count (DirectPurchase) +//! u8 complete (0 = the typed fields do not cover every material +//! field; the row must not be approved without +//! rendering details) +//! u32 details_len, u8[details_len] details (UTF-8; empty when complete) //! kind 3 (IdentityCreditTransfer): //! u8[32] identity_id, u8[32] recipient_id, u64 amount //! kind 4 / 5 (DataContractCreate / DataContractUpdate): @@ -87,6 +97,52 @@ unsafe fn push_cstr(blob: &mut Vec, ptr: *const c_char, what: &str) -> Resul Ok(()) } +/// Append `u32 len + bytes` for an optional C string; null encodes as +/// length 0 (`details` is null exactly when there is nothing to render). +/// +/// # Safety +/// `ptr` must be null or a valid NUL-terminated C string. +unsafe fn push_cstr32_opt( + blob: &mut Vec, + ptr: *const c_char, + what: &str, +) -> Result<(), String> { + let bytes: &[u8] = if ptr.is_null() { + &[] + } else { + CStr::from_ptr(ptr).to_bytes() + }; + push_count(blob, bytes.len(), what)?; + blob.extend_from_slice(bytes); + Ok(()) +} + +/// Append `u32 len + bytes` for a required C string. +/// +/// # Safety +/// `ptr` must be null or a valid NUL-terminated C string. +unsafe fn push_cstr32(blob: &mut Vec, ptr: *const c_char, what: &str) -> Result<(), String> { + if ptr.is_null() { + return Err(format!("{what} is null")); + } + push_cstr32_opt(blob, ptr, what) +} + +/// The `len` elements at `ptr`; a null pointer with a non-zero length is a +/// bug in the producing FFI. +/// +/// # Safety +/// `ptr` must be null or point to `len` initialized elements that outlive `'a`. +unsafe fn ffi_slice<'a, T>(ptr: *const T, len: usize, what: &str) -> Result<&'a [T], String> { + if len == 0 { + return Ok(&[]); + } + if ptr.is_null() { + return Err(format!("{what} pointer is null")); + } + Ok(std::slice::from_raw_parts(ptr, len)) +} + /// # Safety /// `key` must be a row owned by a live `ParsedIdentityUpdateFFI`. unsafe fn push_parsed_public_key( @@ -152,6 +208,12 @@ unsafe fn push_batched_transition( if row.has_recipient { blob.extend_from_slice(&row.recipient_id); } + blob.push(u8::from(row.has_token_count)); + if row.has_token_count { + blob.extend_from_slice(&row.token_count.to_be_bytes()); + } + blob.push(u8::from(row.complete)); + push_cstr32_opt(blob, row.details, "batched transition details")?; Ok(()) } @@ -171,66 +233,54 @@ pub(crate) unsafe fn encode_parsed_state_transition( ) -> 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")?; + // u32: a batch's name lists every transition in it. + push_cstr32(&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, - )); - } + blob.extend_from_slice(&parsed.user_fee_increase.to_be_bytes()); + blob.push(u8::from(parsed.complete)); + let serialized = ffi_slice(parsed.serialized, parsed.serialized_len, "serialized")?; + push_count(&mut blob, serialized.len(), "serialized")?; + blob.extend_from_slice(serialized); + push_cstr32_opt(&mut blob, parsed.details, "details")?; 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)?; - } + let keys = ffi_slice( + update.add_public_keys, + update.add_public_keys_count, + "added keys", + )?; + push_count(&mut blob, keys.len(), "added keys")?; + for key in keys { + push_parsed_public_key(&mut blob, key)?; } - push_count( - &mut blob, + let disabled = ffi_slice( + update.disable_public_key_ids, update.disable_public_key_ids_count, - "disabled keys", + "disabled key ids", )?; - 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()); - } + push_count(&mut blob, disabled.len(), "disabled keys")?; + for id in disabled { + 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)?; - } + let rows = ffi_slice( + batch.transitions, + batch.transitions_count, + "batched transitions", + )?; + push_count(&mut blob, rows.len(), "batched transitions")?; + for row in rows { + push_batched_transition(&mut blob, row)?; } } PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER => { @@ -244,21 +294,14 @@ pub(crate) unsafe fn encode_parsed_state_transition( let contract = &parsed.data_contract; blob.extend_from_slice(&contract.contract_id); blob.extend_from_slice(&contract.owner_id); - push_count( - &mut blob, + let names = ffi_slice( + contract.document_type_names, 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")?; - } + push_count(&mut blob, names.len(), "document type names")?; + for name in names { + push_cstr(&mut blob, *name, "document type name")?; } } _ => {} @@ -326,7 +369,21 @@ mod tests { use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::serialization::PlatformSerializable; + use dpp::platform_value::platform_value; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; use dpp::state_transition::batch_transition::batched_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::{ + DocumentTransferTransition, DocumentTransition, + }; + 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::token_direct_purchase_transition::v0::TokenDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::{ + DocumentCreateTransition, TokenDirectPurchaseTransition, + }; + use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use std::collections::BTreeMap; use dpp::state_transition::batch_transition::batched_transition::{ BatchedTransition, TokenTransition, }; @@ -351,21 +408,40 @@ mod tests { 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()], + 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(), + // A document-type-bound ENCRYPTION key: the bounds carry a + // variable-length document type name before the limits + // flags, so the field order after the string is pinned. + IdentityPublicKeyInCreationV0 { + id: 19, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + read_only: true, + data: BinaryData::new(vec![0x02; 33]), + signature: BinaryData::new(vec![]), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([0x44; 32]), + document_type_name: "profile".to_string(), + }), + } + .into(), + ], disable_public_keys: vec![4], user_fee_increase: 0, } @@ -435,6 +511,10 @@ mod tests { fn u64(&mut self) -> u64 { u64::from_be_bytes(self.take(8).try_into().unwrap()) } + fn str32(&mut self) -> String { + let len = self.u32() as usize; + String::from_utf8(self.take(len).to_vec()).unwrap() + } fn str16(&mut self) -> String { let len = self.u16() as usize; String::from_utf8(self.take(len).to_vec()).unwrap() @@ -448,14 +528,17 @@ mod tests { let mut r = Reader(&blob); assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE); - assert_eq!(r.str16(), "IdentityUpdate"); + assert_eq!(r.str32(), "IdentityUpdate"); assert_eq!(r.u8(), 1); assert_eq!(r.take(32), &[0x11; 32]); assert_eq!(r.u8(), 0, "unsigned"); + assert_eq!(r.u16(), 0, "user fee increase"); + assert_eq!(r.u8(), 1, "complete"); let serialized_len = r.u32() as usize; assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.u32(), 0, "no details for a described kind"); assert_eq!(r.take(32), &[0x11; 32]); - assert_eq!(r.u32(), 1); + assert_eq!(r.u32(), 2); assert_eq!(r.u32(), 18); assert_eq!(r.u8(), 0); // key type assert_eq!(r.u8(), 0); // purpose @@ -468,6 +551,19 @@ mod tests { assert_eq!(r.u8(), 0b11); assert_eq!(r.u64(), 10_000_000_000); assert_eq!(r.u64(), 1_800_000_000_000); + // Second key: SingleContractDocumentType, whose document type name + // sits between the bounds id and the limits flags. + assert_eq!(r.u32(), 19); + assert_eq!(r.u8(), 0); // key type + assert_eq!(r.u8(), 1); // ENCRYPTION + assert_eq!(r.u8(), 3); // MEDIUM + assert_eq!(r.u8(), 1); // read only + assert_eq!(r.u8(), 2); // SingleContractDocumentType + assert_eq!(r.u16(), 33); + assert_eq!(r.take(33), &[0x02; 33]); + assert_eq!(r.take(32), &[0x44; 32]); + assert_eq!(r.str16(), "profile"); + assert_eq!(r.u8(), 0, "no limits after the document type name"); assert_eq!(r.u32(), 1); assert_eq!(r.u32(), 4); assert!(r.0.is_empty(), "trailing bytes"); @@ -485,12 +581,15 @@ mod tests { let mut r = Reader(&blob); assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_BATCH); - assert_eq!(r.str16(), "DocumentsBatch([TokenTransfer])"); + assert_eq!(r.str32(), "DocumentsBatch([TokenTransfer])"); assert_eq!(r.u8(), 1); assert_eq!(r.take(32), &[0x21; 32]); assert_eq!(r.u8(), 0); + assert_eq!(r.u16(), 0, "user fee increase"); + assert_eq!(r.u8(), 1, "complete"); let serialized_len = r.u32() as usize; assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.u32(), 0, "no details for a described kind"); assert_eq!(r.take(32), &[0x21; 32]); assert_eq!(r.u32(), 1); assert_eq!(r.u8(), 1); // token family @@ -502,6 +601,9 @@ mod tests { assert_eq!(r.u64(), 250); assert_eq!(r.u8(), 1); assert_eq!(r.take(32), &[0x22; 32]); + assert_eq!(r.u8(), 0, "no token count"); + assert_eq!(r.u8(), 1, "complete"); + assert_eq!(r.u32(), 0, "no details"); assert!(r.0.is_empty(), "trailing bytes"); assert_eq!( @@ -510,6 +612,129 @@ mod tests { ); } + /// A document create (incomplete: its data renders in details), a + /// document transfer and a token direct purchase, so the blob exercises + /// both row families, the variable-length document type name and the + /// details string before the fields that follow them. + fn mixed_batch_bytes() -> Vec { + let base = |document_type_name: &str| { + 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([0x42; 32]), + }) + }; + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([0x21; 32]), + transitions: vec![ + BatchedTransition::Document(DocumentTransition::Create( + DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base: base("post"), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), platform_value!("hi"))]), + prefunded_voting_balance: None, + }), + )), + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base: base("profile"), + revision: 2, + recipient_owner_id: Identifier::from([0x22; 32]), + }), + )), + 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: None, + }), + token_count: 100, + total_agreed_price: 100_000_000, + }), + )), + ], + user_fee_increase: 7, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + .serialize_to_bytes() + .expect("fixture mixed batch serializes") + } + + #[test] + fn mixed_batch_blob_round_trips_and_is_pinned_for_kotlin() { + let bytes = mixed_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.str32(), + "DocumentsBatch([Create, Transfer, TokenDirectPurchase])" + ); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u8(), 0); + assert_eq!(r.u16(), 7, "user fee increase"); + assert_eq!(r.u8(), 0, "incomplete: the create row renders its data"); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.u32(), 0, "no common details for a described kind"); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u32(), 3); + + // Document create: incomplete, data in details. + assert_eq!(r.u8(), 0); + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "Create"); + assert_eq!(r.str16(), "post"); + assert_eq!(r.take(32), &[0x0D; 32]); + assert_eq!(r.u8(), 0, "no amount"); + assert_eq!(r.u8(), 0, "no recipient"); + assert_eq!(r.u8(), 0, "no token count"); + assert_eq!(r.u8(), 0, "incomplete"); + let details_len = r.u32() as usize; + let details = String::from_utf8(r.take(details_len).to_vec()).unwrap(); + assert!(details.contains("\"message\""), "{details}"); + + // Document transfer: complete, recipient after the strings. + assert_eq!(r.u8(), 0); + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "Transfer"); + assert_eq!(r.str16(), "profile"); + assert_eq!(r.take(32), &[0x0D; 32]); + assert_eq!(r.u8(), 0, "no amount"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x22; 32]); + assert_eq!(r.u8(), 0, "no token count"); + assert_eq!(r.u8(), 1, "complete"); + assert_eq!(r.u32(), 0, "no details"); + + // Direct purchase: amount, token count, complete. + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "DirectPurchase"); + assert_eq!(r.u16(), 3); + assert_eq!(r.take(32), &[0x77; 32]); + assert_eq!(r.u8(), 1); + assert_eq!(r.u64(), 100_000_000); + assert_eq!(r.u8(), 0, "no recipient"); + assert_eq!(r.u8(), 1); + assert_eq!(r.u64(), 100); + assert_eq!(r.u8(), 1, "complete"); + assert_eq!(r.u32(), 0, "no details"); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, MIXED_BATCH_GOLDEN, + "mixed-batch 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 @@ -522,4 +747,8 @@ mod tests { env!("CARGO_MANIFEST_DIR"), "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin" )); + const MIXED_BATCH_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin" + )); } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index f568cbaf56e..cb24adcab6b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -279,45 +279,121 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// `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? - ) + case document(Document) /// 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; + /// `dataContractId`. + case token(Token) + + /// A document transition. `amount` is a purchase price or an + /// update-price value; `recipientId` is a transfer's new owner. + public struct Document: Sendable, Equatable { + public let dataContractId: Identifier + public let documentType: String + public let documentId: Identifier + public let action: String + public let amount: UInt64? + public let recipientId: Identifier? + /// Whether the fields above describe every material field. A + /// `Create`, `Replace` or `IndexOnlyDelete` carries document + /// data that has no typed projection, so it is never complete. + public let complete: Bool + /// The material fields not covered above (the document data, a + /// prefunded voting balance), rendered for display; `nil` when + /// `complete`. **A row with `complete == false` must not be + /// approved from the typed fields alone; render `details`.** + public let details: String? + + public init( + dataContractId: Identifier, documentType: String, documentId: Identifier, + action: String, amount: UInt64?, recipientId: Identifier?, + complete: Bool, details: String? + ) { + self.dataContractId = dataContractId + self.documentType = documentType + self.documentId = documentId + self.action = action + self.amount = amount + self.recipientId = recipientId + self.complete = complete + self.details = details + } + } + + /// 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. 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, - action: String, - amount: UInt64?, - recipientId: Identifier? - ) + /// says "freeze the tokens of X", not "send to X". `tokenCount` + /// is a `DirectPurchase`'s token count. + public struct Token: Sendable, Equatable { + public let dataContractId: Identifier + public let tokenId: Identifier + public let tokenContractPosition: UInt16 + public let action: String + public let amount: UInt64? + public let recipientId: Identifier? + public let tokenCount: UInt64? + /// Whether the fields above describe every material field. + /// `ConfigUpdate`, `EmergencyAction`, `SetPriceForDirectPurchase` + /// and `Claim` never are, nor is any transition carrying a note + /// or group-action info. + public let complete: Bool + /// The material fields not covered above (the config change and + /// who it grants or revokes, the emergency action, the price + /// schedule, the distribution type, notes, group action), + /// rendered for display; `nil` when `complete`. **A row with + /// `complete == false` must not be approved from the typed + /// fields alone; render `details`.** + public let details: String? + + public init( + dataContractId: Identifier, tokenId: Identifier, tokenContractPosition: UInt16, + action: String, amount: UInt64?, recipientId: Identifier?, tokenCount: UInt64?, + complete: Bool, details: String? + ) { + self.dataContractId = dataContractId + self.tokenId = tokenId + self.tokenContractPosition = tokenContractPosition + self.action = action + self.amount = amount + self.recipientId = recipientId + self.tokenCount = tokenCount + self.complete = complete + self.details = details + } + } /// The data contract the transition acts within. public var dataContractId: Identifier { switch self { - case .document(let id, _, _, _, _, _), .token(let id, _, _, _, _, _): - return id + case .document(let row): return row.dataContractId + case .token(let row): return row.dataContractId } } /// The rs-dpp action name. public var action: String { switch self { - case .document(_, _, _, let action, _, _), .token(_, _, _, let action, _, _): - return action + case .document(let row): return row.action + case .token(let row): return row.action + } + } + + /// Whether the typed fields describe every material field. When + /// `false`, `details` must be rendered before approval. + public var complete: Bool { + switch self { + case .document(let row): return row.complete + case .token(let row): return row.complete + } + } + + /// The rendered material fields the typed fields do not cover. + public var details: String? { + switch self { + case .document(let row): return row.details + case .token(let row): return row.details } } } @@ -366,7 +442,12 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// 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`. + /// errors. Kinds this SDK has no describer for arrive as `.other` with + /// the decoded transition in `ParsedStateTransition.details`. + /// + /// Nothing here may be treated as ready to sign unless + /// `ParsedStateTransition.complete` is `true`; otherwise render its + /// `details` and each `.batch` row's `details`. public enum ParsedStateTransitionKind: Sendable, Equatable { /// Key registration / revocation (`IdentityUpdateTransition`). case identityUpdate(ParsedIdentityUpdateTransition) @@ -385,10 +466,9 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// 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 + /// `serialized` holds the decoded transition re-serialized in tagged + /// DPP framing; 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 { @@ -400,8 +480,20 @@ public final class ManagedPlatformWallet: @unchecked Sendable { public let ownerId: Identifier? /// Whether the transition already carries a signature. public let isSigned: Bool + /// Percentage added to the processing fee Platform charges (`0` = + /// none; `65535` is about 656 times the base processing fee). Part + /// of the signed bytes: show a non-zero value on the sheet, and + /// refuse values above what the wallet is willing to pay. + public let userFeeIncrease: UInt16 + /// Whether `kind` shows every material field (computed in Rust). + /// When `false`, render `details` and the batch rows' `details`. + public let complete: Bool /// The decoded bytes, tagged. public let serialized: Data + /// A structured (multi-line) dump: of the whole transition for + /// `.other`, of the whole contract for a data contract create or + /// update. `nil` otherwise. + public let details: String? /// The typed summary. public let kind: ParsedStateTransitionKind @@ -409,13 +501,19 @@ public final class ManagedPlatformWallet: @unchecked Sendable { kindName: String, ownerId: Identifier?, isSigned: Bool, + userFeeIncrease: UInt16, + complete: Bool, serialized: Data, + details: String?, kind: ParsedStateTransitionKind ) { self.kindName = kindName self.ownerId = ownerId self.isSigned = isSigned + self.userFeeIncrease = userFeeIncrease + self.complete = complete self.serialized = serialized + self.details = details self.kind = kind } } @@ -1446,14 +1544,22 @@ extension ManagedPlatformWallet { /// 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 { + public enum ConnectSubFeature: 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 + case sessionAuthentication /// 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 + /// `ConnectKeyPurpose.encryption` or `.decryption`. + case appEncryption + + /// The DIP-13 sub-feature index the FFI derives under. + var ffiValue: UInt32 { + switch self { + case .sessionAuthentication: return UInt32(CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION) + case .appEncryption: return UInt32(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION) + } + } } /// The trailing `purpose'` level of the app-encryption path: the DPP @@ -1461,9 +1567,9 @@ extension ManagedPlatformWallet { /// 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 + public enum ConnectKeyPurpose: Sendable { + case encryption + case decryption /// The DPP `KeyPurpose` the derived half is registered under. public var keyPurpose: KeyPurpose { @@ -1472,6 +1578,14 @@ extension ManagedPlatformWallet { case .decryption: return .decryption } } + + /// The trailing `purpose'` level the FFI appends. + var ffiValue: UInt32 { + switch self { + case .encryption: return UInt32(CONNECT_KEY_PURPOSE_ENCRYPTION) + case .decryption: return UInt32(CONNECT_KEY_PURPOSE_DECRYPTION) + } + } } /// Derive a DashPay Connect key at @@ -1535,10 +1649,10 @@ extension ManagedPlatformWallet { network.ffiValue, walletBytes.bindMemory(to: UInt8.self).baseAddress!, resolver.handle, - subFeature.rawValue, + subFeature.ffiValue, identityBytes.bindMemory(to: UInt8.self).baseAddress!, leafBytes.bindMemory(to: UInt8.self).baseAddress!, - purpose?.rawValue ?? 0, + purpose?.ffiValue ?? 0, &out ) } @@ -3816,13 +3930,13 @@ extension ManagedPlatformWallet { ) throws -> ParsedStateTransition { let kind: ParsedStateTransitionKind switch ffi.kind { - case 1: // PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE + case UInt8(PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE): kind = .identityUpdate( try makeParsedIdentityUpdateTransition(from: ffi.identity_update) ) - case 2: // PARSED_STATE_TRANSITION_KIND_BATCH + case UInt8(PARSED_STATE_TRANSITION_KIND_BATCH): kind = .batch(try makeParsedBatchTransition(from: ffi.batch)) - case 3: // PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER + case UInt8(PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER): kind = .creditTransfer( ParsedCreditTransferTransition( identityId: tupleData(ffi.credit_transfer.identity_id), @@ -3830,15 +3944,15 @@ extension ManagedPlatformWallet { amount: ffi.credit_transfer.amount ) ) - case 4: // PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + case UInt8(PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE): kind = .dataContractCreate( try makeParsedDataContractTransition(from: ffi.data_contract) ) - case 5: // PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE + case UInt8(PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE): kind = .dataContractUpdate( try makeParsedDataContractTransition(from: ffi.data_contract) ) - case 255: // PARSED_STATE_TRANSITION_KIND_OTHER + case UInt8(PARSED_STATE_TRANSITION_KIND_OTHER): kind = .other default: throw PlatformWalletError.deserialization( @@ -3862,11 +3976,26 @@ extension ManagedPlatformWallet { serialized = Data() } + let details: String? + if let detailsPtr = ffi.details { + guard let rendered = String(validatingCString: detailsPtr) else { + throw PlatformWalletError.deserialization( + "Parsed state transition details are not valid UTF-8" + ) + } + details = rendered + } else { + details = nil + } + return ParsedStateTransition( kindName: kindName, ownerId: ownerId, isSigned: ffi.is_signed, + userFeeIncrease: ffi.user_fee_increase, + complete: ffi.complete, serialized: serialized, + details: details, kind: kind ) } @@ -3899,9 +4028,20 @@ extension ManagedPlatformWallet { 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 + let details: String? + if let detailsPtr = entry.details { + guard let rendered = String(validatingCString: detailsPtr) else { + throw PlatformWalletError.deserialization( + "Batched transition \(index) details are not valid UTF-8" + ) + } + details = rendered + } else { + details = nil + } switch entry.family { - case 0: // PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT + case UInt8(PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT): guard let documentTypePtr = entry.document_type, let documentType = String(validatingCString: documentTypePtr) else { throw PlatformWalletError.deserialization( @@ -3909,22 +4049,29 @@ extension ManagedPlatformWallet { ) } return .document( - dataContractId: dataContractId, - documentType: documentType, - documentId: tupleData(entry.document_id), - action: action, - amount: amount, - recipientId: recipientId - ) - case 1: // PARSED_BATCHED_TRANSITION_FAMILY_TOKEN + .init( + dataContractId: dataContractId, + documentType: documentType, + documentId: tupleData(entry.document_id), + action: action, + amount: amount, + recipientId: recipientId, + complete: entry.complete, + details: details + )) + case UInt8(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 - ) + .init( + dataContractId: dataContractId, + tokenId: tupleData(entry.token_id), + tokenContractPosition: entry.token_contract_position, + action: action, + amount: amount, + recipientId: recipientId, + tokenCount: entry.has_token_count ? entry.token_count : nil, + complete: entry.complete, + details: details + )) default: throw PlatformWalletError.deserialization( "Unknown batched transition family \(entry.family) at index \(index)" diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer_max_fee.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer_max_fee.hex new file mode 100644 index 00000000000..a318fa552d5 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer_max_fee.hex @@ -0,0 +1 @@ +070011111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222fb03e801fbffff0000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex new file mode 100644 index 00000000000..335e94089b5 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex @@ -0,0 +1 @@ +05011111111111111111111111111111111111111111111111111111111111111111fc075bcd150700010476a914ab03000000 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 index a898d0e311b..8f453a44c2e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex @@ -1 +1 @@ -000001e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f135010000000001010000010121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f7065727469657313000000000000000000000001000000 +000001e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f13502000000000101000001000121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f7065727469657313000000000000000000000001000000 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 index 9b7463a4f06..72fe6818161 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex @@ -1 +1 @@ -01000201e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f135010000000001010000010121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f70657274696573130000000000000000000000000000 +01000201e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f13502000000000101000001000121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f70657274696573130000000000000000000000000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift index 5057c9eb7f2..2499e804100 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift @@ -48,28 +48,45 @@ final class ParseStateTransitionTests: XCTestCase { 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)) + // The document create carries data with no typed projection, so the + // row is incomplete and renders it. + guard case .document(let create) = batch.transitions[0] else { + return XCTFail("expected a document row") + } + XCTAssertEqual(create.action, "Create") + XCTAssertEqual(create.documentType, "post") + XCTAssertEqual(create.dataContractId, contract) + XCTAssertEqual(create.documentId, Data(repeating: 0x0D, count: 32)) + XCTAssertNil(create.amount) + XCTAssertNil(create.recipientId) + XCTAssertFalse(create.complete) + XCTAssertTrue(create.details?.contains("\"message\"") == true, "\(create.details ?? "")") + XCTAssertFalse(batch.transitions[0].complete) + XCTAssertEqual( batch.transitions[1], .document( - dataContractId: contract, documentType: "profile", - documentId: Data(repeating: 0x0D, count: 32), action: "Transfer", - amount: nil, recipientId: recipient)) + .init( + dataContractId: contract, documentType: "profile", + documentId: Data(repeating: 0x0D, count: 32), action: "Transfer", + amount: nil, recipientId: recipient, complete: true, details: nil))) XCTAssertEqual( batch.transitions[2], .token( - dataContractId: contract, tokenId: token, tokenContractPosition: 3, - action: "Transfer", amount: 250, recipientId: recipient)) + .init( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "Transfer", amount: 250, recipientId: recipient, tokenCount: nil, + complete: true, details: nil))) XCTAssertEqual( batch.transitions[3], .token( - dataContractId: contract, tokenId: token, tokenContractPosition: 3, - action: "DirectPurchase", amount: 100_000_000, recipientId: nil)) + .init( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "DirectPurchase", amount: 100_000_000, recipientId: nil, + tokenCount: 100, complete: true, details: nil))) + XCTAssertEqual(parsed.userFeeIncrease, 1) + XCTAssertFalse(parsed.complete, "the create row renders its data") + XCTAssertNil(parsed.details, "described kinds carry no common details") XCTAssertEqual(batch.transitions.map(\.action), ["Create", "Transfer", "Transfer", "DirectPurchase"]) } @@ -122,6 +139,7 @@ final class ParseStateTransitionTests: XCTestCase { XCTAssertEqual(parsed.kindName, "IdentityCreditTransfer") XCTAssertFalse(parsed.isSigned) + XCTAssertEqual(parsed.userFeeIncrease, 0) XCTAssertEqual( parsed.kind, .creditTransfer( @@ -129,6 +147,8 @@ final class ParseStateTransitionTests: XCTestCase { identityId: Data(repeating: 0x11, count: 32), recipientId: recipient, amount: 1_000))) + XCTAssertTrue(parsed.complete) + XCTAssertNil(parsed.details) } func testParsesDataContractCreateAndUpdate() throws { @@ -139,6 +159,8 @@ final class ParseStateTransitionTests: XCTestCase { let parsed = try wallet.parseStateTransition(try fixture(name)) XCTAssertEqual(parsed.kindName, kindName) XCTAssertEqual(parsed.ownerId, owner) + XCTAssertFalse(parsed.complete, "tokens, groups and schemas are in details") + XCTAssertTrue(parsed.details?.contains("niceDocument") == true) let contract: ManagedPlatformWallet.ParsedDataContractTransition switch parsed.kind { @@ -160,6 +182,34 @@ final class ParseStateTransitionTests: XCTestCase { } } + /// The user fee increase scales the processing fee and is part of the + /// signed bytes, so a maxed-out transfer must not parse to the same + /// approval fields as a plain one. Both fixtures are the same credit + /// transfer, serialized on the Rust side with `user_fee_increase` 0 and + /// 65535. + func testUserFeeIncreaseIsExposed() throws { + let plain = try wallet.parseStateTransition(try fixture("credit_transfer")) + let maxed = try wallet.parseStateTransition(try fixture("credit_transfer_max_fee")) + XCTAssertEqual(plain.userFeeIncrease, 0) + XCTAssertEqual(maxed.userFeeIncrease, 65535) + guard case .creditTransfer(let a) = plain.kind, case .creditTransfer(let b) = maxed.kind + else { return XCTFail("expected credit transfers") } + XCTAssertEqual(a, b, "everything but the fee multiplier is identical") + XCTAssertNotEqual(plain, maxed, "the parsed results differ in the multiplier") + } + + /// Kinds without a describer carry the decoded transition as a + /// structured dump so a withdrawal's amount and destination can be shown. + func testOtherKindCarriesStructuredDetails() throws { + let parsed = try wallet.parseStateTransition(try fixture("credit_withdrawal")) + XCTAssertEqual(parsed.kindName, "IdentityCreditWithdrawal") + XCTAssertEqual(parsed.kind, .other) + XCTAssertFalse(parsed.complete) + let details = try XCTUnwrap(parsed.details) + XCTAssertTrue(details.contains("amount: 123456789"), details) + XCTAssertTrue(details.contains("output_script"), details) + } + func testRejectsEmptyAndMalformedBytes() { XCTAssertThrowsError(try wallet.parseStateTransition(Data())) XCTAssertThrowsError(try wallet.parseStateTransition(Data([0xDE, 0xAD, 0xBE, 0xEF]))) { error in From e188565efae93a0a5c27577d2c32505fc0c2f2ac Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 22 Sep 2026 21:41:58 -0500 Subject: [PATCH 06/20] test(kotlin-sdk): build the unknown-kind parse blob with the u32 name and complete flag Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dashsdk/identity/StateTransitionParserTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 1ba95625364..c56309235f7 100644 --- 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 @@ -209,8 +209,10 @@ class StateTransitionParserTest { @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, 0, 0, 0, 0, 0, 0) + // kind 77, u32 name, no owner, unsigned, fee 0, incomplete, empty + // serialized, empty details. + val blob = byteArrayOf(77, 0, 0, 0, name.size.toByte()) + name + + byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) val error = assertThrows(IllegalArgumentException::class.java) { StateTransitionParser.parseBlob(blob) } From d3fadae83979ec60346c8c61b934dec9c658de06 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 03:08:05 -0500 Subject: [PATCH 07/20] fix(kotlin-sdk): keep parsed state transition amounts unsigned The summary's amounts, token counts and credit transfer amounts are protocol u64s, but the Kotlin blob reader took them as signed Long, so a DirectPurchase with total_agreed_price = u64::MAX parsed as amount -1 while the signed bytes kept the full ceiling. They are now ULong, as DpnsMarketplace already does for credit prices. Key limits stay Long to match IdentityPubkey, and a limit above Long.MAX_VALUE is refused with a message instead of producing a negative value. A shared golden pins a u64::MAX direct purchase from the Rust encoder, decoded by the Kotlin test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dashsdk/identity/StateTransitionParser.kt | 41 +++++++++++----- .../identity/StateTransitionParserTest.kt | 46 ++++++++++++++++-- .../golden/parsed_max_price_purchase_v1.bin | Bin 0 -> 357 bytes .../src/parse_state_transition.rs | 36 ++++++++++++++ 4 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin 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 index a13351bc64a..abb88cf3e9e 100644 --- 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 @@ -32,8 +32,8 @@ sealed class ParsedBatchedTransition { * rendered for display. Null when [complete]. */ abstract val details: String? - /** Credits or tokens the transition moves, when it moves any. */ - abstract val amount: Long? + /** Credits or tokens the transition moves, when it moves any (a protocol u64). */ + abstract val amount: ULong? /** * The identity on the other side of the transition, when there is one. * Interpret it against [action]: a transfer's recipient, a mint's @@ -51,7 +51,7 @@ sealed class ParsedBatchedTransition { val documentType: String, val documentId: ByteArray, override val action: String, - override val amount: Long?, + override val amount: ULong?, override val recipientId: ByteArray?, override val complete: Boolean, override val details: String?, @@ -86,10 +86,10 @@ sealed class ParsedBatchedTransition { val tokenId: ByteArray, val tokenContractPosition: Int, override val action: String, - override val amount: Long?, + override val amount: ULong?, override val recipientId: ByteArray?, /** A `DirectPurchase`'s token count ([amount] is its total agreed price). */ - val tokenCount: Long?, + val tokenCount: ULong?, override val complete: Boolean, override val details: String?, ) : ParsedBatchedTransition() { @@ -133,7 +133,7 @@ sealed class ParsedStateTransitionKind { class CreditTransfer( val identityId: ByteArray, val recipientId: ByteArray, - val amount: Long, + val amount: ULong, ) : ParsedStateTransitionKind() /** `DataContractCreateTransition`. */ @@ -272,7 +272,7 @@ object StateTransitionParser { ParsedStateTransitionKind.Batch(batchOwner, transitions) } KIND_CREDIT_TRANSFER -> - ParsedStateTransitionKind.CreditTransfer(readId32(buf), readId32(buf), buf.long) + ParsedStateTransitionKind.CreditTransfer(readId32(buf), readId32(buf), readU64(buf)) KIND_DATA_CONTRACT_CREATE -> ParsedStateTransitionKind.DataContractCreate(readDataContract(buf)) KIND_DATA_CONTRACT_UPDATE -> @@ -299,8 +299,8 @@ object StateTransitionParser { 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 totalBudget = if (flags and 1 != 0) readLimit(buf, "totalBudget") else null + val expiresAt = if (flags and 2 != 0) readLimit(buf, "expiresAt") else null val bounds: ContractBounds? = when (boundsKind) { 0 -> null @@ -333,9 +333,9 @@ object StateTransitionParser { FAMILY_DOCUMENT -> { val documentType = readString16(buf) val documentId = readId32(buf) - val amount = if (readBool(buf)) buf.long else null + val amount = if (readBool(buf)) readU64(buf) else null val recipient = if (readBool(buf)) readId32(buf) else null - val tokenCount = if (readBool(buf)) buf.long else null + val tokenCount = if (readBool(buf)) readU64(buf) else null require(tokenCount == null) { "malformed parse blob: token count on a document row" } val complete = readBool(buf) val details = readString32(buf).ifEmpty { null } @@ -346,9 +346,9 @@ object StateTransitionParser { FAMILY_TOKEN -> { val position = buf.short.toInt() and 0xFFFF val tokenId = readId32(buf) - val amount = if (readBool(buf)) buf.long else null + val amount = if (readBool(buf)) readU64(buf) else null val recipient = if (readBool(buf)) readId32(buf) else null - val tokenCount = if (readBool(buf)) buf.long else null + val tokenCount = if (readBool(buf)) readU64(buf) else null val complete = readBool(buf) val details = readString32(buf).ifEmpty { null } ParsedBatchedTransition.Token( @@ -383,6 +383,21 @@ object StateTransitionParser { return String(bytes, Charsets.UTF_8) } + /** A protocol u64, big-endian. */ + private fun readU64(buf: ByteBuffer): ULong = buf.long.toULong() + + /** + * A key limit. [IdentityPubkey] carries limits as non-negative `Long`s, so + * a u64 above `Long.MAX_VALUE` is refused rather than read as negative. + */ + private fun readLimit(buf: ByteBuffer, what: String): Long { + val value = readU64(buf) + require(value <= Long.MAX_VALUE.toULong()) { + "unsupported parsed transition: $what $value exceeds ${Long.MAX_VALUE}" + } + return value.toLong() + } + /** `u32 len + UTF-8 bytes`; an empty string encodes "none". */ private fun readString32(buf: ByteBuffer): String = String(readBytes32Len(buf), Charsets.UTF_8) 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 index c56309235f7..fb95f722f71 100644 --- 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 @@ -85,7 +85,7 @@ class StateTransitionParserTest { tokenId = ByteArray(32) { 0x77 }, tokenContractPosition = 3, action = "Transfer", - amount = 250L, + amount = 250uL, recipientId = ByteArray(32) { 0x22 }, tokenCount = null, complete = true, @@ -138,9 +138,9 @@ class StateTransitionParserTest { tokenId = ByteArray(32) { 0x77 }, tokenContractPosition = 3, action = "DirectPurchase", - amount = 100_000_000L, + amount = 100_000_000uL, recipientId = null, - tokenCount = 100L, + tokenCount = 100uL, complete = true, details = null, ), @@ -174,6 +174,46 @@ class StateTransitionParserTest { ) } + /** + * The price ceiling and token count are protocol u64s; `u64::MAX` must come + * through as the full unsigned value, not as a negative amount a spending + * limit check would let through. + */ + @Test + fun `keeps a u64 max purchase price unsigned`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_max_price_purchase_v1.bin")) + val batch = parsed.kind as ParsedStateTransitionKind.Batch + val purchase = batch.transitions.single() as ParsedBatchedTransition.Token + + assertEquals("DirectPurchase", purchase.action) + assertEquals(ULong.MAX_VALUE, purchase.amount) + assertEquals(ULong.MAX_VALUE, purchase.tokenCount) + assertEquals("18446744073709551615", purchase.amount.toString()) + } + + /** + * [IdentityPubkey] carries key limits as non-negative `Long`s; a budget + * above `Long.MAX_VALUE` is refused with a message rather than surfacing + * as a negative value. + */ + @Test + fun `refuses a key budget above Long MAX_VALUE`() { + val golden = golden("parsed_identity_update_v1.bin") + // The golden's limited key carries `total_budget = 10_000_000_000` + // (0x00000002540be400), once inside the serialized transition and + // once in the parsed key that follows it; set the parsed copy's high bit. + val budget = byteArrayOf(0, 0, 0, 2, 0x54, 0x0b, 0xe4.toByte(), 0) + val at = (0..golden.size - budget.size).last { i -> + (budget.indices).all { golden[i + it] == budget[it] } + } + val patched = golden.copyOf().also { it[at] = 0x80.toByte() } + + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(patched) + } + assertTrue(error.message!!.contains("totalBudget")) + } + @Test fun `decodes the other kind with the common fields and details`() { // kind 255, name "MasternodeVote", no owner, signed, fee increase 3, diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..262d769210f83f21ae1a3135dfd8073a0b22105f GIT binary patch literal 357 zcmZQ#U|>*n$xklLP0cGQc1kQs&d`Vs$~l zaXk}3m5hv>3=AyHP6R+X0l@J0KNP?jK>I;}fJ+sDN*Eal*zUx@hZ+P7%mmz1&Iof4 K1Dpj45e5MC@NpOb literal 0 HcmV?d00001 diff --git a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs index 2f5d678437d..ae5e8b33c71 100644 --- a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs +++ b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs @@ -735,6 +735,38 @@ mod tests { ); } + /// A direct purchase whose agreed price ceiling is `u64::MAX`: the blob carries the full + /// unsigned value, which the Kotlin reader must not see as a negative `Long`. + #[test] + fn max_price_purchase_blob_carries_the_unsigned_price() { + let bytes = StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([0x21; 32]), + transitions: 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: None, + }), + token_count: u64::MAX, + total_agreed_price: u64::MAX, + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + .serialize_to_bytes() + .expect("fixture batch serializes"); + let blob = parse_to_blob(&bytes); + assert_eq!( + blob, MAX_PRICE_PURCHASE_GOLDEN, + "max-price 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 @@ -751,4 +783,8 @@ mod tests { env!("CARGO_MANIFEST_DIR"), "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin" )); + const MAX_PRICE_PURCHASE_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin" + )); } From 388d3745fa882c7ffcd9d84f03070585707694b1 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 05:18:16 -0500 Subject: [PATCH 08/20] fix(platform-wallet): show a contract create's group declarations in its summary The data contract create / update summary rendered only the embedded contract in details. A V1 create also carries contract_group (admins who may add members) and contract_group_memberships outside the contract, so two creations differing only there summarized identically while signing different bytes. details now renders the whole transition. A regression test builds two V1 creations that differ only in admins or membership target and requires different details. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../network/state_transition_summary.rs | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index 85d9af76c1a..4522aa93bc2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -129,9 +129,10 @@ pub struct DataContractSummary { pub owner_id: Identifier, /// Document type names, in the contract's order. pub document_type_names: Vec, - /// The whole contract (schemas, tokens and their distribution rules, - /// groups, keywords): everything the names above do not show, and what - /// the registration fee is charged on. + /// The whole transition: the contract (schemas, tokens and their + /// distribution rules, groups, keywords), and for a create the contract + /// group it registers and the memberships it declares. Everything the + /// names above do not show. pub details: String, } @@ -260,11 +261,13 @@ fn summarize(transition: &StateTransition, serialized: Vec) -> StateTransiti StateTransition::DataContractCreate(create) => { StateTransitionSummaryKind::DataContractCreate(summarize_contract( create.data_contract(), + format!("{create:#?}"), )) } StateTransition::DataContractUpdate(update) => { StateTransitionSummaryKind::DataContractUpdate(summarize_contract( update.data_contract(), + format!("{update:#?}"), )) } other => StateTransitionSummaryKind::Other { @@ -282,12 +285,17 @@ fn summarize(transition: &StateTransition, serialized: Vec) -> StateTransiti } } -fn summarize_contract(contract: &DataContractInSerializationFormat) -> DataContractSummary { +/// `details` is the whole transition, not only its contract: a create transition also carries +/// the contract group it registers and the memberships it declares. +fn summarize_contract( + contract: &DataContractInSerializationFormat, + details: String, +) -> DataContractSummary { DataContractSummary { contract_id: contract.id(), owner_id: contract.owner_id(), document_type_names: contract.document_schemas().keys().cloned().collect(), - details: format!("{contract:#?}"), + details, } } @@ -485,6 +493,9 @@ mod tests { use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use dpp::contract_group::{ContractGroupMember, ContractGroupMembership, ContractGroupRegistration}; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV1; + use std::collections::BTreeSet; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; use dpp::tokens::emergency_action::TokenEmergencyAction; @@ -987,6 +998,55 @@ mod tests { ); } + /// A V1 create also registers a contract group and declares memberships + /// outside its contract; two creations that differ only there must not + /// summarize the same. + #[test] + fn a_data_contract_create_details_show_its_group_declarations() { + let created = get_data_contract_fixture( + Some(Identifier::from(OWNER)), + 1, + PlatformVersion::latest().protocol_version, + ); + let contract = DataContractInSerializationFormat::try_from_platform_versioned( + created.data_contract(), + PlatformVersion::latest(), + ) + .expect("serialization format"); + let create = |admin: [u8; 32], group: [u8; 32]| -> StateTransition { + DataContractCreateTransitionV1 { + data_contract: contract.clone(), + identity_nonce: 1, + contract_group: Some(ContractGroupRegistration { + admins: BTreeSet::from([Identifier::from(admin)]), + name: None, + description: None, + }), + contract_group_memberships: vec![ContractGroupMembership { + contract_group_id: Identifier::from(group), + member: ContractGroupMember::Contract, + }], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into() + }; + let details = |transition: &StateTransition| { + let StateTransitionSummaryKind::DataContractCreate(contract) = + summarize_bytes(transition).kind + else { + panic!("expected a contract create"); + }; + contract.details + }; + + let base = details(&create([0xA1; 32], [0xB1; 32])); + assert_ne!(base, details(&create([0xA2; 32], [0xB1; 32])), "admins"); + assert_ne!(base, details(&create([0xA1; 32], [0xB2; 32])), "membership"); + assert!(base.contains("contract_group_memberships"), "{base}"); + } + /// A public note is quoted, so a newline cannot forge another line and a /// NUL cannot break the C projection. #[test] From 5aca65eb3fa3a6866552ed9eace36f2ad927cf8a Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 07:07:28 -0500 Subject: [PATCH 09/20] fix(platform-wallet): escape and bound the details of a state transition summary Every detail is now rendered with compact Debug, which quotes and escapes the strings a dApp controls: token config changes (localized names), the prefunded voting index name, notes. Display implementations interpolated those raw, so a delimiter or newline could make two different changes read the same or forge a line. Identifiers inside details are rewritten to base58 so a user can compare them. Document type names, shown unescaped in their own field, are refused unless they match the consensus pattern ^[a-zA-Z0-9-_]{1,64}$. Rendering runs under one byte budget per summary (MAX_DETAILS_BYTES, 64 times the decoder's 100000-byte limit) enforced while formatting: formatting stops at the first write past it and the summary is refused rather than built or truncated. Pretty Debug of nested untrusted values could otherwise expand a request into megabytes before approval; the densest compact case measured is about 42 times its encoding. Kotlin: added and disabled key ids are protocol u32s; one above Int.MAX_VALUE is refused with its value instead of reading as a negative id. The mixed-batch golden is regenerated for the compact rendering. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dashsdk/identity/StateTransitionParser.kt | 22 +- .../identity/StateTransitionParserTest.kt | 22 ++ .../golden/parsed_mixed_batch_v1.bin | Bin 855 -> 833 bytes .../src/parse_state_transition.rs | 41 ++- .../network/state_transition_summary.rs | 348 ++++++++++++++++-- .../src/parse_state_transition.rs | 5 +- .../ManagedPlatformWallet.swift | 6 +- 7 files changed, 388 insertions(+), 56 deletions(-) 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 index abb88cf3e9e..30375e5c58d 100644 --- 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 @@ -185,9 +185,9 @@ class ParsedStateTransition( /** The decoded bytes, tagged. */ val serialized: ByteArray, /** - * A structured multi-line dump: of the whole transition for - * [ParsedStateTransitionKind.Other], of the whole contract for a data - * contract create / update. Null otherwise. + * A single-line dump with every dApp-controlled string quoted: of the whole + * transition for [ParsedStateTransitionKind.Other] and for a data contract + * create / update. Null otherwise. */ val details: String?, val kind: ParsedStateTransitionKind, @@ -263,7 +263,7 @@ object StateTransitionParser { 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 } + val disabled = List(readCount(buf, "disabled keys")) { readKeyId(buf) } ParsedStateTransitionKind.IdentityUpdate(identityId, added, disabled) } KIND_BATCH -> { @@ -288,7 +288,7 @@ object StateTransitionParser { } private fun readPublicKey(buf: ByteBuffer): IdentityPubkey { - val keyId = buf.int + val keyId = readKeyId(buf) val keyType = buf.get().toInt() and 0xFF val purpose = buf.get().toInt() and 0xFF val securityLevel = buf.get().toInt() and 0xFF @@ -383,6 +383,18 @@ object StateTransitionParser { return String(bytes, Charsets.UTF_8) } + /** + * A protocol u32 key id. Key ids are `Int` in this SDK (see [IdentityPubkey]), + * so one above `Int.MAX_VALUE` is refused rather than read as negative. + */ + private fun readKeyId(buf: ByteBuffer): Int { + val id = buf.int + require(id >= 0) { + "unsupported parsed transition: key id ${id.toUInt()} exceeds ${Int.MAX_VALUE}" + } + return id + } + /** A protocol u64, big-endian. */ private fun readU64(buf: ByteBuffer): ULong = buf.long.toULong() 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 index fb95f722f71..eb8cdf72c67 100644 --- 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 @@ -214,6 +214,28 @@ class StateTransitionParserTest { assertTrue(error.message!!.contains("totalBudget")) } + /** + * Disabled key ids are protocol u32s; one above `Int.MAX_VALUE` is refused + * rather than surfacing as a negative id. + */ + @Test + fun `refuses a disabled key id above Int MAX_VALUE`() { + // kind 1, u32 name, no owner, unsigned, fee 0, complete, empty + // serialized, empty details, identity id, no added keys, one disabled + // key 0xFFFFFFFF. + val name = "IdentityUpdate".toByteArray() + val blob = byteArrayOf(1, 0, 0, 0, name.size.toByte()) + name + + byteArrayOf(0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0) + + ByteArray(32) { 0x11 } + + byteArrayOf(0, 0, 0, 0) + + byteArrayOf(0, 0, 0, 1, -1, -1, -1, -1) + + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(blob) + } + assertTrue(error.message!!.contains("4294967295")) + } + @Test fun `decodes the other kind with the common fields and details`() { // kind 255, name "MasternodeVote", no owner, signed, fee increase 3, diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin index 154bb0f778bd12f956ce389a19bf7f05953b7b01..68a6472b2dad7223ff587998dcc451494b232e0f 100644 GIT binary patch delta 41 wcmcc4c93m@4U?>FN@7W(l|r>rZfbFHVtT5Ql|o2rMTv$|My8VHB7naK`}ER(C5jsXDOmJ);j 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 df8fdcc00d9..448c9e4f65d 100644 --- a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs +++ b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs @@ -209,8 +209,8 @@ pub struct ParsedStateTransitionFFI { /// after approval. pub serialized: *mut u8, pub serialized_len: usize, - /// `OTHER`: `Debug` dump of the whole transition; data contract create / - /// update: of the whole contract. Null otherwise. Owned, NUL-terminated. + /// `OTHER` and data contract create / update: single-line `Debug` dump of + /// the whole transition. Null otherwise. Owned, NUL-terminated. pub details: *mut c_char, /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE`. pub identity_update: ParsedIdentityUpdateFFI, @@ -1184,10 +1184,10 @@ mod tests { assert!(out.serialized.is_null()); } + /// A name that is not a valid document type name is refused before the + /// FFI projects anything. #[test] - 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. + fn rejects_an_invalid_document_type_name() { let bytes = batch_transition_bytes( vec![document_create("post"), document_create("pro\0file")], vec![], @@ -1196,12 +1196,41 @@ mod tests { assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + PlatformWalletFFIResultCode::ErrorDeserialization ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); assert!(out.batch.transitions.is_null()); } + /// When a later row cannot cross the FFI, the rows already projected are + /// released rather than leaked. + #[test] + fn a_row_that_cannot_cross_the_ffi_releases_the_projected_rows() { + use platform_wallet::wallet::identity::network::BatchedTransitionSummary; + + let row = |action: &str| BatchedTransitionSummary { + data_contract_id: Identifier::from(CONTRACT), + action: action.to_string(), + target: BatchedTransitionTarget::Document { + document_type: "post".to_string(), + document_id: Identifier::from([0x0D; 32]), + }, + amount: None, + recipient_id: None, + token_count: None, + details: Some("data".to_string()), + }; + let result = + project_parsed_batch(&Identifier::from(OWNER), &[row("Create"), row("Cre\0ate")]); + let Err(error) = result else { + panic!("an interior NUL must not cross the FFI"); + }; + assert_eq!( + error.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + #[test] fn freeing_a_default_value_is_a_safe_no_op() { let mut out = ParsedStateTransitionFFI::default(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index 4522aa93bc2..30dae81f4f5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -10,6 +10,8 @@ //! //! This module does not sign and does not broadcast. +use std::fmt; + use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::identity::KeyID; use dpp::prelude::{Identifier, UserFeeIncrease}; @@ -46,7 +48,9 @@ use dpp::state_transition::data_contract_update_transition::accessors::DataContr use dpp::state_transition::identity_credit_transfer_transition::accessors::IdentityCreditTransferTransitionAccessorsV0; use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; -use dpp::state_transition::{StateTransition, StateTransitionOwned, StateTransitionType}; +use dpp::state_transition::{ + StateTransition, StateTransitionOwned, StateTransitionType, STATE_TRANSITION_MAX_ENCODED_BYTES, +}; use crate::error::PlatformWalletError; @@ -191,7 +195,7 @@ pub fn summarize_state_transition( bytes: &[u8], ) -> Result { let (transition, serialized) = decode_state_transition(bytes, UNTAGGED_STATE_TRANSITION_KINDS)?; - Ok(summarize(&transition, serialized)) + summarize(&transition, serialized) } /// Decodes `bytes` as a tagged `StateTransition` or as one of `untagged_kinds` @@ -240,7 +244,27 @@ pub fn decode_state_transition( } } -fn summarize(transition: &StateTransition, serialized: Vec) -> StateTransitionSummary { +/// Document type names reach the approval sheet as their own field, unescaped. Consensus only +/// accepts `^[a-zA-Z0-9-_]{1,64}$`, so anything else is refused here rather than shown. +fn check_document_type_name(name: &str) -> Result<(), PlatformWalletError> { + let valid = (1..=64).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if valid { + Ok(()) + } else { + Err(PlatformWalletError::InvalidParameter(format!( + "Invalid document type name {name:?}" + ))) + } +} + +fn summarize( + transition: &StateTransition, + serialized: Vec, +) -> Result { + let mut budget = DetailsBudget::default(); let kind = match transition { StateTransition::IdentityUpdate(update) => StateTransitionSummaryKind::IdentityUpdate { identity_id: update.identity_id(), @@ -249,7 +273,7 @@ fn summarize(transition: &StateTransition, serialized: Vec) -> StateTransiti }, StateTransition::Batch(batch) => StateTransitionSummaryKind::Batch { owner_id: batch.owner_id(), - transitions: summarize_batch(batch), + transitions: summarize_batch(batch, &mut budget)?, }, StateTransition::IdentityCreditTransfer(transfer) => { StateTransitionSummaryKind::CreditTransfer { @@ -261,50 +285,152 @@ fn summarize(transition: &StateTransition, serialized: Vec) -> StateTransiti StateTransition::DataContractCreate(create) => { StateTransitionSummaryKind::DataContractCreate(summarize_contract( create.data_contract(), - format!("{create:#?}"), - )) + budget.render(create)?, + )?) } StateTransition::DataContractUpdate(update) => { StateTransitionSummaryKind::DataContractUpdate(summarize_contract( update.data_contract(), - format!("{update:#?}"), - )) + budget.render(update)?, + )?) } other => StateTransitionSummaryKind::Other { - details: format!("{other:#?}"), + details: budget.render(other)?, }, }; - StateTransitionSummary { + Ok(StateTransitionSummary { kind_name: transition.name(), owner_id: transition.owner_id(), is_signed: transition.signature().is_some_and(|sig| !sig.is_empty()), user_fee_increase: transition.user_fee_increase(), serialized, kind, + }) +} + +/// Total bytes of `details` one summary may render: 64 times the largest transition the decoder +/// accepts. Compact `Debug` of the densest values measured (a minimal token configuration) is +/// about 42 times its encoding, so a valid request fits; nested values cannot grow past the bound. +pub const MAX_DETAILS_BYTES: usize = 64 * STATE_TRANSITION_MAX_ENCODED_BYTES; + +/// Renders `details` with compact `Debug`, which quotes and escapes every string a dApp controls, +/// under one byte budget for the whole summary. Formatting stops as soon as the budget is spent, +/// and the summary is refused rather than shown truncated. +struct DetailsBudget { + limit: usize, + used: usize, +} + +impl Default for DetailsBudget { + fn default() -> Self { + Self { + limit: MAX_DETAILS_BYTES, + used: 0, + } } } +impl DetailsBudget { + fn render(&mut self, value: &dyn fmt::Debug) -> Result { + struct Bounded<'a> { + out: String, + remaining: &'a mut usize, + } + impl fmt::Write for Bounded<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + *self.remaining = self.remaining.checked_sub(s.len()).ok_or(fmt::Error)?; + self.out.push_str(s); + Ok(()) + } + } + + let mut remaining = self.limit - self.used; + let mut sink = Bounded { + out: String::new(), + remaining: &mut remaining, + }; + fmt::write(&mut sink, format_args!("{value:?}")).map_err(|_| { + PlatformWalletError::InvalidParameter(format!( + "State transition details exceed {} bytes; refusing to summarize it", + self.limit + )) + })?; + let out = base58_identifiers(&sink.out); + self.used = self.limit - remaining; + Ok(out) + } + + /// `label: `. + fn line(&mut self, label: &str, value: &dyn fmt::Debug) -> Result { + Ok(format!("{label}: {}", self.render(value)?)) + } +} + +/// Rewrites every `Identifier(IdentifierBytes32([b0, .., b31]))` that `Debug` produces as +/// `Identifier()`, the form a user can compare with other tools. Only exact 32-byte runs +/// are rewritten, and the output is never longer than the input. +fn base58_identifiers(rendered: &str) -> String { + const PREFIX: &str = "Identifier(IdentifierBytes32(["; + const SUFFIX: &str = "]))"; + let mut out = String::with_capacity(rendered.len()); + let mut rest = rendered; + while let Some(start) = rest.find(PREFIX) { + out.push_str(&rest[..start]); + let after = &rest[start + PREFIX.len()..]; + let parsed = after.find(SUFFIX).and_then(|end| { + let bytes: Vec = after[..end] + .split(", ") + .map(str::parse) + .collect::>() + .ok()?; + let bytes: [u8; 32] = bytes.try_into().ok()?; + Some((Identifier::from(bytes), end)) + }); + match parsed { + Some((id, end)) => { + out.push_str(&format!("Identifier({id})")); + rest = &after[end + SUFFIX.len()..]; + } + None => { + out.push_str(PREFIX); + rest = after; + } + } + } + out.push_str(rest); + out +} + /// `details` is the whole transition, not only its contract: a create transition also carries /// the contract group it registers and the memberships it declares. fn summarize_contract( contract: &DataContractInSerializationFormat, details: String, -) -> DataContractSummary { - DataContractSummary { +) -> Result { + let document_type_names: Vec = contract.document_schemas().keys().cloned().collect(); + for name in &document_type_names { + check_document_type_name(name)?; + } + Ok(DataContractSummary { contract_id: contract.id(), owner_id: contract.owner_id(), - document_type_names: contract.document_schemas().keys().cloned().collect(), + document_type_names, details, - } + }) } -fn summarize_batch(batch: &BatchTransition) -> Vec { +fn summarize_batch( + batch: &BatchTransition, + budget: &mut DetailsBudget, +) -> Result, PlatformWalletError> { batch .transitions_iter() .map(|transition| match transition { - BatchedTransitionRef::Document(document) => summarize_document_transition(document), - BatchedTransitionRef::Token(token) => summarize_token_transition(token), + BatchedTransitionRef::Document(document) => { + summarize_document_transition(document, budget) + } + BatchedTransitionRef::Token(token) => summarize_token_transition(token, budget), }) .collect() } @@ -313,7 +439,11 @@ fn join_details(lines: Vec) -> Option { (!lines.is_empty()).then(|| lines.join("\n")) } -fn summarize_document_transition(transition: &DocumentTransition) -> BatchedTransitionSummary { +fn summarize_document_transition( + transition: &DocumentTransition, + budget: &mut DetailsBudget, +) -> Result { + check_document_type_name(transition.document_type_name())?; let (amount, recipient_id) = match transition { DocumentTransition::Transfer(t) => (None, Some(t.recipient_owner_id())), DocumentTransition::Purchase(t) => (Some(t.price()), None), @@ -326,24 +456,25 @@ fn summarize_document_transition(transition: &DocumentTransition) -> BatchedTran let mut lines = Vec::new(); if let Some(data) = transition.data() { - lines.push(format!("data: {data:#?}")); + lines.push(budget.line("data", data)?); } if let DocumentTransition::Create(create) = transition { if let Some((index_name, credits)) = create.prefunded_voting_balance() { lines.push(format!( - "prefunded voting balance: {credits} credits for index {index_name}" + "prefunded voting balance: {credits} credits for index {}", + budget.render(index_name)? )); } } let base = transition.base(); if let Some(payment) = base.token_payment_info() { - lines.push(format!("token payment: {payment:?}")); + lines.push(budget.line("token payment", &payment)?); } if let Some(fees) = base.action_fee_agreement() { - lines.push(format!("action fees: {fees:?}")); + lines.push(budget.line("action fees", &fees)?); } - BatchedTransitionSummary { + Ok(BatchedTransitionSummary { data_contract_id: transition.data_contract_id(), action: format!("{:?}", transition.action_type()), target: BatchedTransitionTarget::Document { @@ -354,10 +485,13 @@ fn summarize_document_transition(transition: &DocumentTransition) -> BatchedTran recipient_id, token_count: None, details: join_details(lines), - } + }) } -fn summarize_token_transition(transition: &TokenTransition) -> BatchedTransitionSummary { +fn summarize_token_transition( + transition: &TokenTransition, + budget: &mut DetailsBudget, +) -> Result { let mut lines = Vec::new(); let (amount, recipient_id, token_count, public_note) = match transition { TokenTransition::Transfer(t) => { @@ -398,31 +532,27 @@ fn summarize_token_transition(transition: &TokenTransition) -> BatchedTransition None, ), TokenTransition::Claim(t) => { - lines.push(format!("distribution type: {}", t.distribution_type())); + lines.push(budget.line("distribution type", &t.distribution_type())?); (None, None, None, t.public_note()) } TokenTransition::EmergencyAction(t) => { - lines.push(format!("emergency action: {:?}", t.emergency_action())); + lines.push(budget.line("emergency action", &t.emergency_action())?); (None, None, None, t.public_note()) } TokenTransition::ConfigUpdate(t) => { - lines.push(format!( - "config update: {}", - t.update_token_configuration_item() - )); + lines.push(budget.line("config update", t.update_token_configuration_item())?); (None, None, None, t.public_note()) } TokenTransition::SetPriceForDirectPurchase(t) => { match t.price() { - Some(schedule) => lines.push(format!("price schedule: {schedule}")), + Some(schedule) => lines.push(budget.line("price schedule", schedule)?), None => lines.push("price schedule: removed (not for sale)".to_string()), } (None, None, None, t.public_note()) } }; - // Debug-quoted so a note cannot forge further lines or carry a NUL. if let Some(note) = public_note { - lines.push(format!("public note: {note:?}")); + lines.push(budget.line("public note", note)?); } let base = transition.base(); if let Some(group) = base.using_group_info() { @@ -438,7 +568,7 @@ fn summarize_token_transition(transition: &TokenTransition) -> BatchedTransition )); } - BatchedTransitionSummary { + Ok(BatchedTransitionSummary { data_contract_id: transition.data_contract_id(), action: transition.action_type().to_string(), target: BatchedTransitionTarget::Token { @@ -449,7 +579,7 @@ fn summarize_token_transition(transition: &TokenTransition) -> BatchedTransition recipient_id, token_count, details: join_details(lines), - } + }) } #[cfg(test)] @@ -493,6 +623,11 @@ mod tests { use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use dpp::data_contract::associated_token::token_configuration_convention::v0::TokenConfigurationConventionV0; + use dpp::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; + use dpp::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0; + use dpp::data_contract::associated_token::token_configuration_localization::TokenConfigurationLocalization; + use dpp::platform_value::Value; use dpp::contract_group::{ContractGroupMember, ContractGroupMembership, ContractGroupRegistration}; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV1; use std::collections::BTreeSet; @@ -797,7 +932,7 @@ mod tests { .collect(); assert_ne!(details[0], details[1]); - assert!(details[0].contains("Manual Minting"), "{}", details[0]); + assert!(details[0].contains("ManualMinting"), "{}", details[0]); assert!(details[0].contains("ContractOwner"), "{}", details[0]); assert!( details[0].contains("public note: \"grant\""), @@ -805,7 +940,7 @@ mod tests { details[0] ); assert!( - details[1].contains(&format!("{}", Identifier::from(RECIPIENT))), + details[1].contains(&format!("Identifier({})", Identifier::from(RECIPIENT))), "{}", details[1] ); @@ -814,7 +949,7 @@ mod tests { "{}", details[2] ); - assert!(details[3].contains("SinglePrice: 4200"), "{}", details[3]); + assert!(details[3].contains("SinglePrice(4200)"), "{}", details[3]); assert!( details[4].contains("distribution type: Perpetual"), "{}", @@ -1047,6 +1182,141 @@ mod tests { assert!(base.contains("contract_group_memberships"), "{base}"); } + /// dApp-chosen strings inside a config change (token names) and a + /// prefunded voting index name stay quoted inside their own field, so + /// delimiters and newlines cannot make two changes read the same or forge + /// a line. + #[test] + fn dapp_strings_in_details_stay_inside_their_field() { + let conventions = |singular: &str, plural: &str| { + BatchedTransition::Token(TokenTransition::ConfigUpdate( + TokenConfigUpdateTransition::V0(TokenConfigUpdateTransitionV0 { + base: token_base(), + update_token_configuration_item: TokenConfigurationChangeItem::Conventions( + TokenConfigurationConvention::V0(TokenConfigurationConventionV0 { + localizations: BTreeMap::from([( + "en".to_string(), + TokenConfigurationLocalization::V0( + TokenConfigurationLocalizationV0 { + should_capitalize: false, + singular_form: singular.to_string(), + plural_form: plural.to_string(), + }, + ), + )]), + decimals: 8, + }), + ), + public_note: None, + }), + )) + }; + let details = |transition: BatchedTransition| { + let StateTransitionSummaryKind::Batch { transitions, .. } = + summarize_bytes(&batch(vec![transition])).kind + else { + panic!("expected a batch"); + }; + transitions[0].details.clone().expect("details rendered") + }; + + let a = details(conventions("a', Plural: 'b", "c")); + let b = details(conventions("a", "b', Plural: 'c")); + assert_ne!(a, b); + let forged = details(conventions("x\npublic note: \"trusted\"", "y")); + assert_eq!(forged.lines().count(), 1, "{forged}"); + + let create = BatchedTransition::Document(DocumentTransition::Create( + DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base: document_base("post"), + entropy: [0xEE; 32], + data: BTreeMap::new(), + prefunded_voting_balance: Some(("idx\npublic note: \"x\"".to_string(), 1)), + }), + )); + let index = details(create); + assert!( + !index.lines().any(|line| line.starts_with("public note")), + "{index}" + ); + } + + /// Rendering stops at the first write past the budget and the summary is + /// refused, without formatting the rest or truncating it. + #[test] + fn details_beyond_the_budget_are_refused() { + use std::cell::Cell; + + /// Writes `chunks` pieces of 100 bytes, counting how many it wrote. + struct Chunks<'a> { + chunks: usize, + written: &'a Cell, + } + impl fmt::Debug for Chunks<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for _ in 0..self.chunks { + f.write_str(&"x".repeat(100))?; + self.written.set(self.written.get() + 1); + } + Ok(()) + } + } + + let written = Cell::new(0); + let mut budget = DetailsBudget { + limit: 1_000, + used: 0, + }; + let refused = budget.render(&Chunks { + chunks: 1_000, + written: &written, + }); + assert!(matches!( + refused, + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("exceed") + )); + assert_eq!(written.get(), 10, "formatting stopped at the budget"); + + let mut budget = DetailsBudget::default(); + let data = Value::Array((0..1_000).map(Value::U64).collect()); + let rendered = budget.render(&data).expect("fits the default budget"); + assert_eq!(budget.used, rendered.len()); + } + + /// Identifiers inside details read as base58, as everywhere else a user + /// compares them. + #[test] + fn identifiers_in_details_render_as_base58() { + let id = Identifier::from(core::array::from_fn::(|i| i as u8)); + let mut budget = DetailsBudget::default(); + let rendered = budget + .render(&AuthorizedActionTakers::Identity(id)) + .expect("renders"); + assert_eq!(rendered, format!("Identity(Identifier({id}))")); + } + + /// A document type name outside the consensus pattern is refused rather + /// than shown raw in its own field. + #[test] + fn invalid_document_type_names_are_refused() { + for name in ["post\u{202e}", "a\nb", "", &"x".repeat(65)] { + let transition = batch(vec![BatchedTransition::Document( + DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base(name), + entropy: [0xEE; 32], + data: BTreeMap::new(), + prefunded_voting_balance: None, + }, + )), + )]); + assert!( + summarize_state_transition(&transition.serialize_to_bytes().unwrap()).is_err(), + "{name:?}" + ); + } + } + /// A public note is quoted, so a newline cannot forge another line and a /// NUL cannot break the C projection. #[test] diff --git a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs index ae5e8b33c71..a1add5d6756 100644 --- a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs +++ b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs @@ -27,9 +27,8 @@ //! u16 user_fee_increase (percentage added to the processing fee) //! u8 complete (0 = render details / the rows' details before approval) //! u32 serialized_len, u8[serialized_len] serialized (tagged DPP bytes) -//! u32 details_len, u8[details_len] details (UTF-8; the Debug dump of the -//! whole transition for kind 255, of the contract for kinds 4 / 5, -//! empty otherwise) +//! u32 details_len, u8[details_len] details (UTF-8; the single-line Debug +//! dump of the whole transition for kinds 4 / 5 / 255, empty otherwise) //! kind 1 (IdentityUpdate): //! u8[32] identity_id //! u32 add_count; repeat add_count times (same field order as diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index cb24adcab6b..b08f0178df6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -490,9 +490,9 @@ public final class ManagedPlatformWallet: @unchecked Sendable { public let complete: Bool /// The decoded bytes, tagged. public let serialized: Data - /// A structured (multi-line) dump: of the whole transition for - /// `.other`, of the whole contract for a data contract create or - /// update. `nil` otherwise. + /// A single-line dump with every dApp-controlled string quoted: of + /// the whole transition for `.other` and for a data contract create + /// or update. `nil` otherwise. public let details: String? /// The typed summary. public let kind: ParsedStateTransitionKind From e40901dd22216fecd616a7f96b4a200ce856cb91 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 08:55:55 -0500 Subject: [PATCH 10/20] fix(platform-wallet): leave summary details as the escaped Debug output The base58 rewrite of identifiers ran over the whole Debug output, including quoted dApp strings, so a document text spelled like an identifier rendered the same as a real identifier while the signed bytes differed. Details are now the escaped compact Debug output, unmodified. A regression requires an identifier, its Debug spelling as text and its base58 spelling as text to render three different details. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../network/state_transition_summary.rs | 77 ++++++++----------- 1 file changed, 31 insertions(+), 46 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index 30dae81f4f5..524b2f05ca3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -356,7 +356,7 @@ impl DetailsBudget { self.limit )) })?; - let out = base58_identifiers(&sink.out); + let out = sink.out; self.used = self.limit - remaining; Ok(out) } @@ -367,41 +367,6 @@ impl DetailsBudget { } } -/// Rewrites every `Identifier(IdentifierBytes32([b0, .., b31]))` that `Debug` produces as -/// `Identifier()`, the form a user can compare with other tools. Only exact 32-byte runs -/// are rewritten, and the output is never longer than the input. -fn base58_identifiers(rendered: &str) -> String { - const PREFIX: &str = "Identifier(IdentifierBytes32(["; - const SUFFIX: &str = "]))"; - let mut out = String::with_capacity(rendered.len()); - let mut rest = rendered; - while let Some(start) = rest.find(PREFIX) { - out.push_str(&rest[..start]); - let after = &rest[start + PREFIX.len()..]; - let parsed = after.find(SUFFIX).and_then(|end| { - let bytes: Vec = after[..end] - .split(", ") - .map(str::parse) - .collect::>() - .ok()?; - let bytes: [u8; 32] = bytes.try_into().ok()?; - Some((Identifier::from(bytes), end)) - }); - match parsed { - Some((id, end)) => { - out.push_str(&format!("Identifier({id})")); - rest = &after[end + SUFFIX.len()..]; - } - None => { - out.push_str(PREFIX); - rest = after; - } - } - } - out.push_str(rest); - out -} - /// `details` is the whole transition, not only its contract: a create transition also carries /// the contract group it registers and the memberships it declares. fn summarize_contract( @@ -940,7 +905,7 @@ mod tests { details[0] ); assert!( - details[1].contains(&format!("Identifier({})", Identifier::from(RECIPIENT))), + details[1].contains(&format!("{:?}", Identifier::from(RECIPIENT))), "{}", details[1] ); @@ -1283,16 +1248,36 @@ mod tests { assert_eq!(budget.used, rendered.len()); } - /// Identifiers inside details read as base58, as everywhere else a user - /// compares them. + /// Details are the escaped `Debug` output untouched: a document string + /// spelled like an identifier stays distinct from the identifier itself. #[test] - fn identifiers_in_details_render_as_base58() { - let id = Identifier::from(core::array::from_fn::(|i| i as u8)); - let mut budget = DetailsBudget::default(); - let rendered = budget - .render(&AuthorizedActionTakers::Identity(id)) - .expect("renders"); - assert_eq!(rendered, format!("Identity(Identifier({id}))")); + fn identifier_shaped_strings_stay_distinct_from_identifiers() { + let create = |message: Value| { + BatchedTransition::Document(DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base("post"), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), message)]), + prefunded_voting_balance: None, + }, + ))) + }; + let details = |transition: BatchedTransition| { + let StateTransitionSummaryKind::Batch { transitions, .. } = + summarize_bytes(&batch(vec![transition])).kind + else { + panic!("expected a batch"); + }; + transitions[0].details.clone().expect("details rendered") + }; + let id = Identifier::from([0; 32]); + + let as_identifier = details(create(Value::Identifier(id.to_buffer()))); + let as_debug_text = details(create(Value::Text(format!("{id:?}")))); + let as_base58_text = details(create(Value::Text(format!("Identifier({id})")))); + assert_ne!(as_identifier, as_debug_text); + assert_ne!(as_identifier, as_base58_text); + assert_ne!(as_debug_text, as_base58_text); } /// A document type name outside the consensus pattern is refused rather From de593e10e2afc0d5a20eb9d13456f04861403eed Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 11:51:13 -0500 Subject: [PATCH 11/20] refactor(platform-wallet): derive connect keys with key-wallet's DIP-13 application paths The connect key paths now come from key-wallet's application_session_authentication_path and application_encryption_path (rust-dashcore#1049) instead of being assembled here. ConnectKey names the two key kinds, and the FFI maps its (sub_feature, purpose) pair onto one, refusing combinations DIP-13 does not define (6 with a purpose, 7 without one). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/derive_connect_key.rs | 162 ++++--- .../identity/network/identity_handle.rs | 425 +++++------------- .../src/wallet/identity/network/mod.rs | 7 +- 3 files changed, 194 insertions(+), 400 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs index c49e68932e2..4e3b256280b 100644 --- a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs +++ b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs @@ -18,7 +18,12 @@ //! mnemonic is pulled through the Swift/Kotlin-owned `MnemonicResolver` //! for the duration of the call only, in a zeroized buffer. -use platform_wallet::wallet::identity::network::derive_connect_keypair_from_master; +use key_wallet::bip32::ApplicationKeyPurpose; +use key_wallet::dip9::{ + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION, + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION, +}; +use platform_wallet::wallet::identity::network::{derive_connect_keypair_from_master, ConnectKey}; use crate::error::*; use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; @@ -44,16 +49,18 @@ pub const CONNECT_KEY_PURPOSE_ENCRYPTION: u32 = 1; /// `Purpose::DECRYPTION` discriminant). pub const CONNECT_KEY_PURPOSE_DECRYPTION: u32 = 2; -// cbindgen only exports literal constants; these pin the literals above to the -// values the derivation actually uses. +// cbindgen only exports literal constants; these pin the literals above to +// key-wallet's. const _: () = assert!( CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION - == platform_wallet::wallet::identity::network::CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION + == FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION ); const _: () = assert!( CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION - == platform_wallet::wallet::identity::network::CONNECT_SUB_FEATURE_APP_ENCRYPTION + == FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION ); +const _: () = assert!(CONNECT_KEY_PURPOSE_ENCRYPTION == ApplicationKeyPurpose::Encryption as u32); +const _: () = assert!(CONNECT_KEY_PURPOSE_DECRYPTION == ApplicationKeyPurpose::Decryption as u32); /// A derived DashPay Connect keypair. Plain old data: the caller copies /// what it needs and calls [`dash_sdk_derive_connect_key_free`] to wipe @@ -82,24 +89,17 @@ impl Default for ConnectDerivedKeyFFI { } } -/// Derive the DashPay Connect key at -/// `m/9'/coin'/5'/'/0'/'/'[/']` -/// from the mnemonic the resolver returns for `wallet_id_bytes`. +/// Derive a DashPay Connect key from the mnemonic the resolver returns for +/// `wallet_id_bytes`, at the DIP-13 application paths key-wallet builds. /// -/// - `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. +/// - `sub_feature` `6` with `purpose` `0`: the session authentication key; +/// `leaf_bytes` is the connect request id. +/// - `sub_feature` `7` with `purpose` `1` (ENCRYPTION) or `2` (DECRYPTION): +/// one half of the app encryption pair; `leaf_bytes` is the bound contract +/// id. +/// +/// Any other combination is refused with `ErrorInvalidParameter`. +/// `network` picks the coin type (`5'` mainnet, `1'` otherwise). /// /// The derived key is written to `out_key`; call /// [`dash_sdk_derive_connect_key_free`] when done with it. @@ -128,25 +128,40 @@ pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( 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 => { + 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 key = match (sub_feature, purpose) { + (CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, 0) => ConnectKey::SessionAuthentication { + identity_id, + request_id: leaf, + }, + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_PURPOSE_ENCRYPTION) => { + ConnectKey::AppEncryption { + identity_id, + contract_id: leaf, + purpose: ApplicationKeyPurpose::Encryption, + } + } + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_PURPOSE_DECRYPTION) => { + ConnectKey::AppEncryption { + identity_id, + contract_id: leaf, + purpose: ApplicationKeyPurpose::Decryption, + } + } + (sub_feature, purpose) => { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, format!( - "purpose {other} is not a DashPay Connect key purpose (0 = none, \ - 1 = ENCRYPTION, 2 = DECRYPTION)" + "sub_feature {sub_feature} with purpose {purpose} is not a DashPay Connect \ + key (6 with purpose 0, or 7 with purpose 1 or 2)" ), ); } }; - 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 kw_network: Network = network.into(); let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) .try_into() @@ -157,14 +172,7 @@ pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( kw_network, )); - let derived = derive_connect_keypair_from_master( - &master, - kw_network, - sub_feature, - &identity_id, - leaf, - purpose, - ); + let derived = derive_connect_keypair_from_master(&master, kw_network, key); // `ExtendedPrivKey` does not wipe itself; erase before any return. master.private_key.non_secure_erase(); let derived = unwrap_result_or_return!(derived); @@ -294,10 +302,10 @@ mod tests { let expected = derive_connect_keypair_from_master( &master, Network::Testnet, - CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, - &Identifier::from(IDENTITY), - LEAF, - None, + ConnectKey::SessionAuthentication { + identity_id: Identifier::from(IDENTITY), + request_id: LEAF, + }, ) .unwrap(); assert_eq!(first.private_key_bytes, *expected.private_key); @@ -306,9 +314,7 @@ mod tests { 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. + /// Leaf, purpose and sub-feature each change the key. #[test] fn different_leaves_purposes_and_sub_features_give_different_keys() { let resolver = unsafe { @@ -326,11 +332,10 @@ mod tests { [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]; + let all = [auth, auth_other_leaf, enc, dec]; for (i, a) in all.iter().enumerate() { for b in &all[i + 1..] { assert_ne!(a, b, "connect keys collided (index {i})"); @@ -429,30 +434,39 @@ mod tests { 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. + /// Only session authentication with no purpose, and app encryption with + /// ENCRYPTION or DECRYPTION, 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) }; + fn other_sub_feature_and_purpose_combinations_are_invalid_parameters() { + for (sub_feature, purpose) in [ + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 3), + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 0), + (CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, 1), + (5, 0), + ] { + 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(), + sub_feature, + IDENTITY.as_ptr(), + LEAF.as_ptr(), + purpose, + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "sub_feature {sub_feature} purpose {purpose}" + ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!(message.contains(&format!("purpose {purpose}")), "{message}"); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } } } 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 6699955ac99..acd64703f9a 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 @@ -27,7 +27,9 @@ use dashcore::secp256k1::PublicKey; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyType}; use dpp::prelude::Identifier; -use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType}; +use key_wallet::bip32::{ + ApplicationKeyPurpose, ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType, +}; use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; @@ -114,112 +116,73 @@ 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")?); +/// A DashPay Connect key: the DIP-13 application sub-features (dashpay/dips#191), whose paths +/// key-wallet builds. Identity, request and contract ids are DIP-14 256-bit children, so two +/// devices restored from one seed derive the same key without coordination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectKey { + /// `m/9'/coin'/5'/6'/0'/identity_id'/request_id'`; `request_id` is + /// `hash256` of the app's request public key. + SessionAuthentication { + identity_id: Identifier, + request_id: [u8; 32], + }, + /// `m/9'/coin'/5'/7'/0'/identity_id'/contract_id'/purpose'`: one half of + /// the encryption pair an identity holds for a contract. + AppEncryption { + identity_id: Identifier, + contract_id: [u8; 32], + purpose: ApplicationKeyPurpose, + }, +} + +impl ConnectKey { + /// The key's derivation path on `network`. + pub fn derivation_path(&self, network: key_wallet::Network) -> DerivationPath { + match *self { + ConnectKey::SessionAuthentication { + identity_id, + request_id, + } => DerivationPath::application_session_authentication_path( + network, + identity_id.to_buffer(), + request_id, + ), + ConnectKey::AppEncryption { + identity_id, + contract_id, + purpose, + } => DerivationPath::application_encryption_path( + network, + identity_id.to_buffer(), + contract_id, + 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…'`. +/// Derive the ECDSA secp256k1 keypair of `key` 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. pub fn derive_connect_keypair_from_master( master: &ExtendedPrivKey, network: key_wallet::Network, - sub_feature: u32, - identity_id: &Identifier, - leaf: [u8; 32], - purpose: Option, + key: ConnectKey, ) -> 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(); + let path = key.derivation_path(network); // 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 derived = master.derive_priv(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to derive connect key: {e}")) })?; - let extended_pub = ExtendedPubKey::from_priv(&secp, &derived); + let extended_pub = ExtendedPubKey::from_priv(&derived); Ok(DerivedIdentityAuthKey { derivation_path: path, - private_key: Zeroizing::new(derived.private_key.secret_bytes()), + private_key: Zeroizing::new(derived.private_key.to_secret_bytes()), public_key: extended_pub.public_key.serialize(), }) } @@ -749,255 +712,73 @@ mod tests { } } - // ── DashPay Connect sub-feature derivation ──────────────────────── + // ── DashPay Connect keys ────────────────────────────────────────── 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'" - ); + fn session_key() -> ConnectKey { + ConnectKey::SessionAuthentication { + identity_id: Identifier::from(CONNECT_IDENTITY), + request_id: CONNECT_LEAF, + } + } - // 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()); + fn encryption_key(purpose: ApplicationKeyPurpose) -> ConnectKey { + ConnectKey::AppEncryption { + identity_id: Identifier::from(CONNECT_IDENTITY), + contract_id: CONNECT_LEAF, + purpose, + } } - /// 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. + /// The keys key-wallet pins for these inputs; a change orphans every key + /// already registered on-chain. The public key is the compressed point of + /// the returned scalar. #[test] - fn connect_keypair_is_deterministic_from_fixed_vectors() { - use dashcore::secp256k1::{PublicKey as SecpPublicKey, Secp256k1, SecretKey}; + fn connect_keypairs_match_the_pinned_vectors() { + use dashcore::secp256k1::{PublicKey as SecpPublicKey, 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!( + for (network, key, expected) in [ ( - hex::encode(testnet_auth.public_key), - hex::encode(mainnet_enc.public_key) + Network::Testnet, + session_key(), + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d", ), ( - CONNECT_TESTNET_AUTH_PUBKEY_HEX.to_string(), - CONNECT_MAINNET_ENCRYPTION_PUBKEY_HEX.to_string() + Network::Mainnet, + encryption_key(ApplicationKeyPurpose::Encryption), + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434", ), - "connect pubkey vectors drifted (testnet session-auth, mainnet app-encryption)" - ); + ] { + let derived = derive_connect_keypair_from_master(&master_for(network), network, key) + .expect("connect derive"); + assert_eq!(hex::encode(derived.public_key), expected); + assert_eq!(derived.derivation_path, key.derivation_path(network)); + + let secret: [u8; 32] = *derived.private_key; + let sk = SecretKey::from_secret_bytes(secret).expect("valid scalar"); + assert_eq!( + SecpPublicKey::from_secret_key(&sk).serialize(), + derived.public_key + ); + } } - /// 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. + /// The two halves of an encryption pair and the session key are distinct. #[test] - fn connect_keypair_differs_across_leaves_purposes_and_sub_features() { - let identity = Identifier::from(CONNECT_IDENTITY); - let other_identity = Identifier::from([0x36; 32]); + fn connect_keys_are_distinct_per_kind_and_purpose() { 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) + let derive = |key| { + derive_connect_keypair_from_master(&master, Network::Testnet, key) .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})"); - } - } + let session = derive(session_key()); + let encryption = derive(encryption_key(ApplicationKeyPurpose::Encryption)); + let decryption = derive(encryption_key(ApplicationKeyPurpose::Decryption)); + assert_ne!(session, encryption); + assert_ne!(session, decryption); + assert_ne!(encryption, decryption); } - - // `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 1b66ae49938..1f1ad1c09a3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -102,10 +102,9 @@ 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, CONNECT_SUB_FEATURE_APP_ENCRYPTION, CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + 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, ConnectKey, DerivedIdentityAuthKey, IdentityWallet, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use state_transition_summary::{ From a7ca5c852864ff8c1516ab5cae64e2da766404ab Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 11:59:24 -0500 Subject: [PATCH 12/20] docs(platform-wallet): state which connect key sub-feature and purpose pairs derive The Swift, Kotlin and JNI docs still described purpose as an optional extra level for either sub-feature; the FFI now refuses anything but session authentication without a purpose and app encryption with one. Also tighten the FFI rejection test to check the whole message, and correct the comment that claimed ExtendedPrivKey does not wipe itself (key-wallet zeroizes it on drop since the secp256k1 0.33 bump). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dashfoundation/dashsdk/ffi/IdentityNative.kt | 6 +++--- .../dashsdk/wallet/PlatformWalletManager.kt | 8 ++++---- .../src/derive_connect_key.rs | 8 ++++++-- .../wallet/identity/network/identity_handle.rs | 15 ++++++++------- packages/rs-unified-sdk-jni/src/identity.rs | 2 +- .../PlatformWallet/ManagedPlatformWallet.swift | 7 +++---- 6 files changed, 25 insertions(+), 21 deletions(-) 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 af933655de6..1fe90dd9615 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 @@ -163,9 +163,9 @@ internal object IdentityNative { * 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. + * @param purpose 0 with sub-feature 6 (no purpose level), or the half + * of an encryption pair with sub-feature 7: 1 ENCRYPTION, + * 2 DECRYPTION. Any other combination throws. * @return `[privateKey(32), publicKey(33)]`. */ external fun deriveConnectKeyWithResolver( 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 7ed57ac7dbf..1fe2572e8fc 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 @@ -674,10 +674,10 @@ class PlatformWalletManager( * 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). + * wallet-local counter is an input. [ConnectSubFeature.APP_ENCRYPTION] + * requires a [purpose], which picks the half of its pair; + * [ConnectSubFeature.SESSION_AUTHENTICATION] requires null. Any other + * combination throws. * * @return `(privateKey(32), publicKey(33))`. Caller zeroes the private half. */ diff --git a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs index 4e3b256280b..0e726223610 100644 --- a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs +++ b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs @@ -173,7 +173,8 @@ pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( )); let derived = derive_connect_keypair_from_master(&master, kw_network, key); - // `ExtendedPrivKey` does not wipe itself; erase before any return. + // Dropping `master` zeroizes it too; erase now so the scalar does not + // outlive the derive. master.private_key.non_secure_erase(); let derived = unwrap_result_or_return!(derived); @@ -465,7 +466,10 @@ mod tests { "sub_feature {sub_feature} purpose {purpose}" ); let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); - assert!(message.contains(&format!("purpose {purpose}")), "{message}"); + assert!( + message.contains(&format!("sub_feature {sub_feature} with purpose {purpose}")), + "{message}" + ); unsafe { platform_wallet_ffi_result_free(&mut result) }; } } 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 acd64703f9a..a5a360951f5 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 @@ -116,13 +116,14 @@ pub fn identity_auth_derivation_path_for_type( ])) } -/// A DashPay Connect key: the DIP-13 application sub-features (dashpay/dips#191), whose paths -/// key-wallet builds. Identity, request and contract ids are DIP-14 256-bit children, so two -/// devices restored from one seed derive the same key without coordination. +/// A DashPay Connect key: the DIP-13 application sub-features +/// (dashpay/dips#191), whose paths key-wallet builds. Identity, request and +/// contract ids are DIP-14 256-bit children, so two devices restored from +/// one seed derive the same key without coordination. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectKey { /// `m/9'/coin'/5'/6'/0'/identity_id'/request_id'`; `request_id` is - /// `hash256` of the app's request public key. + /// `hash256` of the app's ephemeral public key. SessionAuthentication { identity_id: Identifier, request_id: [u8; 32], @@ -162,9 +163,9 @@ impl ConnectKey { } } -/// Derive the ECDSA secp256k1 keypair of `key` 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. +/// Derive the ECDSA secp256k1 keypair of `key` 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. pub fn derive_connect_keypair_from_master( master: &ExtendedPrivKey, network: key_wallet::Network, diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 9b5ef3b4176..5051a3bbd54 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -508,7 +508,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive throw_sdk_exception(env, 1, "subFeature must be non-negative"); return ptr::null_mut(); } - // The FFI refuses a purpose other than 0, 1 or 2. + // The FFI refuses any combination other than (6, 0), (7, 1), (7, 2). if purpose < 0 { throw_sdk_exception(env, 1, "purpose must be non-negative"); return ptr::null_mut(); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index b08f0178df6..3031e5ee433 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1596,10 +1596,9 @@ extension ManagedPlatformWallet { /// /// `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). + /// seed derive the same key. `.appEncryption` requires a `purpose`, + /// which picks the half of its pair; `.sessionAuthentication` requires + /// `nil`. Any other combination throws `invalidParameter`. /// /// - Parameters: /// - subFeature: `.sessionAuthentication` or `.appEncryption`. From 16d550ac98d07c001a1606fa3c1743ff4c49a528 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:45:18 -0500 Subject: [PATCH 13/20] fix(platform-wallet): charge every detail byte to the budget and report witness-signed transitions as signed `DetailsBudget` charged only the `Debug` value: labels, fixed lines and the newlines joining lines were appended uncharged, so returned details could exceed `MAX_DETAILS_BYTES`. Every fragment now goes through one bounded `write`, and `join` charges its separators. `is_signed` read only `StateTransition::signature()`, which is `None` for the kinds spending platform addresses (address funds transfer / withdrawal, identity create / top-up from addresses, shield), so a transfer carrying its input witnesses reported unsigned. Those kinds now count as signed when they carry a witness; kinds authorized only by a shielded proof still report `false`, as documented on the field. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../network/state_transition_summary.rs | 154 ++++++++++++++---- 1 file changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index 524b2f05ca3..ebcf1c3c635 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -16,6 +16,7 @@ use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::identity::KeyID; use dpp::prelude::{Identifier, UserFeeIncrease}; use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; 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; @@ -71,8 +72,10 @@ pub struct StateTransitionSummary { /// The identity the transition acts for; `None` for the asset-lock-funded /// and shielded kinds. pub owner_id: Option, - /// Whether the transition already carries a non-empty signature. A `sign` - /// request must arrive unsigned. + /// Whether the transition already carries a non-empty signature: its own + /// signature, or for the kinds spending platform addresses any input + /// witness. Kinds authorized only by a shielded proof report `false`. A + /// `sign` request must arrive unsigned. pub is_signed: bool, /// Percentage added to the processing fee Platform charges (`65535` is /// about 656 times the base). Part of the signed bytes, so an approval @@ -302,21 +305,37 @@ fn summarize( Ok(StateTransitionSummary { kind_name: transition.name(), owner_id: transition.owner_id(), - is_signed: transition.signature().is_some_and(|sig| !sig.is_empty()), + is_signed: carries_signature(transition), user_fee_increase: transition.user_fee_increase(), serialized, kind, }) } +/// Whether `transition` carries a non-empty signature or, for the kinds spending platform +/// addresses, any input witness; [`StateTransition::signature`] alone is `None` for those. +fn carries_signature(transition: &StateTransition) -> bool { + let has_witness = match transition { + StateTransition::IdentityCreateFromAddresses(st) => !st.witnesses().is_empty(), + StateTransition::IdentityTopUpFromAddresses(st) => !st.witnesses().is_empty(), + StateTransition::AddressFundsTransfer(st) => !st.witnesses().is_empty(), + StateTransition::AddressFundingFromAssetLock(st) => !st.witnesses().is_empty(), + StateTransition::AddressCreditWithdrawal(st) => !st.witnesses().is_empty(), + StateTransition::Shield(st) => !st.witnesses().is_empty(), + _ => false, + }; + has_witness || transition.signature().is_some_and(|sig| !sig.is_empty()) +} + /// Total bytes of `details` one summary may render: 64 times the largest transition the decoder /// accepts. Compact `Debug` of the densest values measured (a minimal token configuration) is /// about 42 times its encoding, so a valid request fits; nested values cannot grow past the bound. pub const MAX_DETAILS_BYTES: usize = 64 * STATE_TRANSITION_MAX_ENCODED_BYTES; /// Renders `details` with compact `Debug`, which quotes and escapes every string a dApp controls, -/// under one byte budget for the whole summary. Formatting stops as soon as the budget is spent, -/// and the summary is refused rather than shown truncated. +/// under one byte budget for the whole summary: values, labels, fixed text and the newlines +/// joining lines are all charged. Formatting stops as soon as the budget is spent, and the +/// summary is refused rather than shown truncated. struct DetailsBudget { limit: usize, used: usize, @@ -332,7 +351,8 @@ impl Default for DetailsBudget { } impl DetailsBudget { - fn render(&mut self, value: &dyn fmt::Debug) -> Result { + /// Formats `args` against the remaining budget. + fn write(&mut self, args: fmt::Arguments<'_>) -> Result { struct Bounded<'a> { out: String, remaining: &'a mut usize, @@ -350,20 +370,38 @@ impl DetailsBudget { out: String::new(), remaining: &mut remaining, }; - fmt::write(&mut sink, format_args!("{value:?}")).map_err(|_| { - PlatformWalletError::InvalidParameter(format!( - "State transition details exceed {} bytes; refusing to summarize it", - self.limit - )) - })?; + fmt::write(&mut sink, args).map_err(|_| self.exceeded())?; let out = sink.out; self.used = self.limit - remaining; Ok(out) } + fn exceeded(&self) -> PlatformWalletError { + PlatformWalletError::InvalidParameter(format!( + "State transition details exceed {} bytes; refusing to summarize it", + self.limit + )) + } + + /// ``. + fn render(&mut self, value: &dyn fmt::Debug) -> Result { + self.write(format_args!("{value:?}")) + } + /// `label: `. fn line(&mut self, label: &str, value: &dyn fmt::Debug) -> Result { - Ok(format!("{label}: {}", self.render(value)?)) + self.write(format_args!("{label}: {value:?}")) + } + + /// `lines` joined by newlines, which are charged too; `None` when there are none. + fn join(&mut self, lines: Vec) -> Result, PlatformWalletError> { + let separators = lines.len().saturating_sub(1); + self.used = self + .used + .checked_add(separators) + .filter(|used| *used <= self.limit) + .ok_or_else(|| self.exceeded())?; + Ok((!lines.is_empty()).then(|| lines.join("\n"))) } } @@ -400,10 +438,6 @@ fn summarize_batch( .collect() } -fn join_details(lines: Vec) -> Option { - (!lines.is_empty()).then(|| lines.join("\n")) -} - fn summarize_document_transition( transition: &DocumentTransition, budget: &mut DetailsBudget, @@ -425,10 +459,9 @@ fn summarize_document_transition( } if let DocumentTransition::Create(create) = transition { if let Some((index_name, credits)) = create.prefunded_voting_balance() { - lines.push(format!( - "prefunded voting balance: {credits} credits for index {}", - budget.render(index_name)? - )); + lines.push(budget.write(format_args!( + "prefunded voting balance: {credits} credits for index {index_name:?}" + ))?); } } let base = transition.base(); @@ -449,7 +482,7 @@ fn summarize_document_transition( amount, recipient_id, token_count: None, - details: join_details(lines), + details: budget.join(lines)?, }) } @@ -461,10 +494,10 @@ fn summarize_token_transition( let (amount, recipient_id, token_count, public_note) = match transition { TokenTransition::Transfer(t) => { if t.shared_encrypted_note().is_some() { - lines.push("shared encrypted note: present".to_string()); + lines.push(budget.write(format_args!("shared encrypted note: present"))?); } if t.private_encrypted_note().is_some() { - lines.push("private encrypted note: present".to_string()); + lines.push(budget.write(format_args!("private encrypted note: present"))?); } ( Some(t.amount()), @@ -475,7 +508,9 @@ fn summarize_token_transition( } TokenTransition::Mint(t) => { if t.issued_to_identity_id().is_none() { - lines.push("issued to: the token's default destination".to_string()); + lines.push( + budget.write(format_args!("issued to: the token's default destination"))?, + ); } ( Some(t.amount()), @@ -511,7 +546,8 @@ fn summarize_token_transition( TokenTransition::SetPriceForDirectPurchase(t) => { match t.price() { Some(schedule) => lines.push(budget.line("price schedule", schedule)?), - None => lines.push("price schedule: removed (not for sale)".to_string()), + None => lines + .push(budget.write(format_args!("price schedule: removed (not for sale)"))?), } (None, None, None, t.public_note()) } @@ -521,7 +557,7 @@ fn summarize_token_transition( } let base = transition.base(); if let Some(group) = base.using_group_info() { - lines.push(format!( + lines.push(budget.write(format_args!( "group action: position {}, action id {}, {}", group.group_contract_position, group.action_id, @@ -530,7 +566,7 @@ fn summarize_token_transition( } else { "signing an existing proposal" } - )); + ))?); } Ok(BatchedTransitionSummary { @@ -543,7 +579,7 @@ fn summarize_token_transition( amount, recipient_id, token_count, - details: join_details(lines), + details: budget.join(lines)?, }) } @@ -843,6 +879,35 @@ mod tests { assert_eq!(maxed.user_fee_increase, u16::MAX); } + /// An address funds transfer has no signature of its own; it is signed by + /// its input witnesses, so it reports signed once it carries one. + #[test] + fn a_witness_signed_transfer_reports_whether_it_is_signed() { + use dpp::address_funds::{AddressWitness, PlatformAddress}; + use dpp::platform_value::BinaryData; + use dpp::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0; + use dpp::state_transition::address_funds_transfer_transition::AddressFundsTransferTransition; + + let transfer = |input_witnesses: Vec| -> StateTransition { + AddressFundsTransferTransition::V0(AddressFundsTransferTransitionV0 { + inputs: BTreeMap::from([(PlatformAddress::P2pkh([0xab; 20]), (1, 5_000))]), + outputs: BTreeMap::from([(PlatformAddress::P2pkh([0xcd; 20]), 4_000)]), + fee_strategy: vec![], + user_fee_increase: 0, + input_witnesses, + }) + .into() + }; + + assert!(!summarize_bytes(&transfer(vec![])).is_signed); + assert!( + summarize_bytes(&transfer(vec![AddressWitness::P2pkh { + signature: BinaryData::new(vec![0x1f; 65]), + }])) + .is_signed + ); + } + /// Two config updates granting manual minting to different takers must not /// summarize the same; neither is complete from the typed fields alone. #[test] @@ -1248,6 +1313,37 @@ mod tests { assert_eq!(budget.used, rendered.len()); } + /// Labels, fixed text and the newlines joining lines are charged with the + /// values, so what is returned never exceeds the budget. + #[test] + fn labels_fixed_text_and_separators_are_charged() { + let render = |budget: &mut DetailsBudget| -> Result, PlatformWalletError> { + let lines = vec![ + budget.line("public note", &"hi")?, + budget.write(format_args!("issued to: the token's default destination"))?, + ]; + budget.join(lines) + }; + + let mut budget = DetailsBudget::default(); + let details = render(&mut budget).unwrap().expect("two lines"); + assert_eq!( + details, + "public note: \"hi\"\nissued to: the token's default destination" + ); + assert_eq!(budget.used, details.len()); + + // Exactly the two lines fit, but not the newline joining them. + let mut budget = DetailsBudget { + limit: details.len() - 1, + used: 0, + }; + assert!(matches!( + render(&mut budget), + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("exceed") + )); + } + /// Details are the escaped `Debug` output untouched: a document string /// spelled like an identifier stays distinct from the identifier itself. #[test] From 9f7e9f2e544eaaae7098aef10736c6f604d90906 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:45:18 -0500 Subject: [PATCH 14/20] test(platform-wallet-ffi): refuse trailing bytes through the public parser; drop orphaned test docs Six doc comments stacked above `fixture_bytes_are_pinned_for_the_client_suites` described tests that live in platform-wallet's summary module (fee multiplier, config-update takers, emergency / price / claim details, the framing collision, trailing bytes), so rustdoc attached them to the fixture test and implied FFI coverage that did not exist. Keep the fixture test's own paragraph, and add the FFI-level regression that a complete contract create parses while the same bytes plus one more are refused. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/parse_state_transition.rs | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) 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 448c9e4f65d..d639f4d3b04 100644 --- a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs +++ b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs @@ -1091,20 +1091,29 @@ mod tests { .expect("fixture withdrawal serializes") } - /// The user fee increase is part of the signed bytes and scales the - /// processing fee, so two otherwise identical transfers must not parse - /// to the same approval fields. - /// A token config update naming one action taker must not parse to the - /// same row as one naming another; the change item is rendered in - /// `details` and the row is marked incomplete. - /// Emergency action, price schedule and claim distribution type were - /// exposed by the old single-purchase parser's predecessor and must not - /// be lost: each renders in `details` with the row marked incomplete. - /// The tagged 2,340-byte contract create also decodes as a 47-byte - /// identity update when the IdentityUpdate tag is prepended; only the - /// framing that consumes every byte counts, so the contract parses. - /// Trailing bytes after a complete transition are refused, since the - /// summary would not show whatever they carry. + /// Bytes left over after a complete transition are refused through the + /// public parser, since the summary would not show whatever they carry. + /// (Framing ambiguity and prefix decodes are covered where the decoding + /// lives, in `platform_wallet`'s `state_transition_summary` tests.) + #[test] + fn trailing_bytes_after_a_complete_transition_are_refused() { + let (bytes, _, _) = data_contract_create_bytes(); + let (result, mut out) = parse(&bytes); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + + let mut padded = bytes; + padded.push(0); + let (result, out) = parse(&padded); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorDeserialization + ); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.serialized.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 From db1d112eafcaf37d946568878e302eb444b86382 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:59:40 -0500 Subject: [PATCH 15/20] fix(platform-wallet): summarize v5.0-dev's shielded-pool token rows and renamed token payment accessor Restacked onto v5.0-dev (via #4932), the summary no longer compiled: token shielded pools (#4760) added seven `TokenTransition` variants, and the document base's token payment is now read through `token_payment_info_ref`. The shielded-pool rows (shield, unshield, shielded transfer, mint / burn / claim / direct purchase to pool) have no typed projection, so the whole transition is rendered in `details` and the row is incomplete, like the other untyped material fields. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../network/state_transition_summary.rs | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index ebcf1c3c635..d8a9f8818c0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -465,8 +465,8 @@ fn summarize_document_transition( } } let base = transition.base(); - if let Some(payment) = base.token_payment_info() { - lines.push(budget.line("token payment", &payment)?); + if let Some(payment) = base.token_payment_info_ref() { + lines.push(budget.line("token payment", payment)?); } if let Some(fees) = base.action_fee_agreement() { lines.push(budget.line("action fees", &fees)?); @@ -551,6 +551,18 @@ fn summarize_token_transition( } (None, None, None, t.public_note()) } + // Shielded-pool operations carry notes, proofs and amounts with no typed projection: the + // whole transition is rendered and the row is incomplete. + TokenTransition::Shield(_) + | TokenTransition::Unshield(_) + | TokenTransition::ShieldedTransfer(_) + | TokenTransition::MintToPool(_) + | TokenTransition::BurnFromPool(_) + | TokenTransition::ClaimToPool(_) + | TokenTransition::DirectPurchaseToPool(_) => { + lines.push(budget.line("transition", transition)?); + (None, None, None, None) + } }; if let Some(note) = public_note { lines.push(budget.line("public note", note)?); @@ -908,6 +920,33 @@ mod tests { ); } + /// A shielded-pool token operation has no typed projection: it renders + /// whole, so its amount reaches the details, and the row is incomplete. + #[test] + fn a_shielded_pool_token_row_renders_whole_and_is_incomplete() { + use dpp::state_transition::batch_transition::batched_transition::token_mint_to_pool_transition::TokenMintToPoolTransitionV0; + use dpp::state_transition::batch_transition::TokenMintToPoolTransition; + + let summary = summarize_bytes(&batch(vec![BatchedTransition::Token( + TokenTransition::MintToPool(TokenMintToPoolTransition::V0( + TokenMintToPoolTransitionV0 { + base: token_base(), + amount: 7_654_321, + ..Default::default() + }, + )), + )])); + let StateTransitionSummaryKind::Batch { transitions, .. } = &summary.kind else { + panic!("a batch: {:?}", summary.kind) + }; + assert_eq!(transitions.len(), 1); + assert!(!transitions[0].is_complete()); + assert_eq!(transitions[0].amount, None); + let details = transitions[0].details.as_deref().expect("details"); + assert!(details.contains("amount: 7654321"), "{details}"); + assert!(!summary.is_complete()); + } + /// Two config updates granting manual minting to different takers must not /// summarize the same; neither is complete from the typed fields alone. #[test] From eaa08f910f8020831c4a71bc576fdffca99eb8e0 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 13 Oct 2026 22:48:00 -0500 Subject: [PATCH 16/20] fix(platform-wallet): refuse invalid document-type bounds on an identity update's added keys A key bound to one document type carries that type's name to the approval sheet in its own field, unescaped, just like a batch row's document type. The summary already refuses batch and contract document-type names outside the consensus pattern, but passed an added key's bounds name through and reported the update as complete. Apply the same check to every added key. Co-Authored-By: Claude Opus 5.5 --- .../network/state_transition_summary.rs | 66 +++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index d8a9f8818c0..50722ca61ad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -13,6 +13,7 @@ use std::fmt; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dpp::identity::contract_bounds::ContractBounds; use dpp::identity::KeyID; use dpp::prelude::{Identifier, UserFeeIncrease}; use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; @@ -48,6 +49,7 @@ use dpp::state_transition::data_contract_create_transition::accessors::DataContr use dpp::state_transition::data_contract_update_transition::accessors::DataContractUpdateTransitionAccessorsV0; use dpp::state_transition::identity_credit_transfer_transition::accessors::IdentityCreditTransferTransitionAccessorsV0; 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::IdentityPublicKeyInCreation; use dpp::state_transition::{ StateTransition, StateTransitionOwned, StateTransitionType, STATE_TRANSITION_MAX_ENCODED_BYTES, @@ -269,11 +271,23 @@ fn summarize( ) -> Result { let mut budget = DetailsBudget::default(); let kind = match transition { - StateTransition::IdentityUpdate(update) => StateTransitionSummaryKind::IdentityUpdate { - identity_id: update.identity_id(), - add_public_keys: update.public_keys_to_add().to_vec(), - disable_public_key_ids: update.public_key_ids_to_disable().to_vec(), - }, + StateTransition::IdentityUpdate(update) => { + // A key bound to one document type carries that type's name to the approval + // sheet in its own field, like a batch row's document type. + for key in update.public_keys_to_add() { + if let Some(ContractBounds::SingleContractDocumentType { + document_type_name, .. + }) = key.contract_bounds() + { + check_document_type_name(document_type_name)?; + } + } + StateTransitionSummaryKind::IdentityUpdate { + identity_id: update.identity_id(), + add_public_keys: update.public_keys_to_add().to_vec(), + disable_public_key_ids: update.public_key_ids_to_disable().to_vec(), + } + } StateTransition::Batch(batch) => StateTransitionSummaryKind::Batch { owner_id: batch.owner_id(), transitions: summarize_batch(batch, &mut budget)?, @@ -1437,6 +1451,48 @@ mod tests { } } + /// An added key bound to one document type shows that type's name in its + /// own field, so a name outside the consensus pattern is refused like a + /// batch row's; a valid one is summarized. + #[test] + fn invalid_document_type_bounds_of_added_keys_are_refused() { + let update = |document_type_name: &str| -> Vec { + let transition: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + revision: 1, + nonce: 1, + 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![0xbb; 65]), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([0x22; 32]), + document_type_name: document_type_name.to_string(), + }), + total_budget: None, + expires_at: None, + } + .into()], + ..Default::default() + } + .into(); + transition.serialize_to_bytes().unwrap() + }; + + let summary = summarize_state_transition(&update("post")).expect("summarizes"); + assert!(summary.is_complete()); + for name in ["post\u{202e}", "a\nb", "", &"x".repeat(65)] { + assert!( + summarize_state_transition(&update(name)).is_err(), + "{name:?}" + ); + } + } + /// A public note is quoted, so a newline cannot forge another line and a /// NUL cannot break the C projection. #[test] From 7d8a1e4715f0dae8001c2159fb49ec2a26e4f8af Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 13 Oct 2026 22:48:30 -0500 Subject: [PATCH 17/20] fix(kotlin-sdk): report an unreadable parsed state transition blob as a DashSdkError `StateTransitionParser.parse` threw the synthesized `DashSDKException` for a blob it could not decode after leaving `mapNativeErrors`, so callers handling the public `DashSdkError` hierarchy missed it. This is not only layout drift: the decoder refuses key limits above `Long.MAX_VALUE` and key ids above `Int.MAX_VALUE` that Rust writes in full. Decode through `decodeBlob`, which maps the failure like every native error. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/identity/StateTransitionParser.kt | 17 ++++++++++++----- .../identity/StateTransitionParserTest.kt | 7 +++++++ 2 files changed, 19 insertions(+), 5 deletions(-) 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 index 30375e5c58d..08ab77cf535 100644 --- 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 @@ -226,13 +226,20 @@ object StateTransitionParser { // the external fun resolves to UnsatisfiedLinkError. Sdk.initialize() val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) } - return try { + return decodeBlob(blob) + } + + /** + * [parseBlob], with a blob this decoder cannot read reported as a + * [org.dashfoundation.dashsdk.errors.DashSdkError] like every native + * failure, so callers have one error path. That happens on a Rust/Kotlin + * layout drift and on values the Kotlin types cannot hold (a key limit + * above `Long.MAX_VALUE`, a key id above `Int.MAX_VALUE`). + */ + internal fun decodeBlob(blob: ByteArray): ParsedStateTransition = mapNativeErrors { + 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}", 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 index eb8cdf72c67..f06abe2a306 100644 --- 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 @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.identity +import org.dashfoundation.dashsdk.errors.DashSdkError import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -212,6 +213,12 @@ class StateTransitionParserTest { StateTransitionParser.parseBlob(patched) } assertTrue(error.message!!.contains("totalBudget")) + + // `parse` reports the same refusal through the public error hierarchy. + val mapped = assertThrows(DashSdkError.InternalError::class.java) { + StateTransitionParser.decodeBlob(patched) + } + assertTrue(mapped.message!!.contains("totalBudget")) } /** From 814c53dc1a547e281922e00f363fd8cae38a8e6a Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 14 Oct 2026 09:47:09 -0500 Subject: [PATCH 18/20] fix(platform-wallet-ffi): refuse invalid document-type bounds in the identity update parser `platform_wallet_parse_identity_update_transition` projects an added key's document-type bound into its own approval field, but only refused names that could not become a C string, so newlines and display-control characters reached the approval sheet unescaped. Share the summary's consensus-pattern check as `check_document_type_name` and apply it before projecting the bound, so both public parsers give the same guarantee. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 81 ++++++++++++++++--- .../src/wallet/identity/network/mod.rs | 4 +- .../network/state_transition_summary.rs | 5 +- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index d7eda4950b0..32b7de4c8bb 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -23,7 +23,9 @@ use dpp::state_transition::public_key_in_creation::accessors::{ }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::{StateTransition, StateTransitionType}; -use platform_wallet::wallet::identity::network::decode_state_transition; +use platform_wallet::wallet::identity::network::{ + check_document_type_name, decode_state_transition, +}; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -111,8 +113,9 @@ fn parse_identity_update_transition_bytes( /// Reporting a narrower bound than the transition declares would let the user /// approve a broader scope than the one they were shown, so a document type -/// that cannot cross the FFI as a C string is an error rather than a fallback -/// to the contract-only bound. +/// that cannot be shown as is is an error rather than a fallback to the +/// contract-only bound. The name is the approval sheet's own field, so it must +/// pass the same consensus-pattern check the state transition summary applies. fn encode_contract_bounds( bounds: Option<&ContractBounds>, ) -> Result<(u8, [u8; 32], *mut c_char), PlatformWalletFFIResult> { @@ -121,16 +124,22 @@ fn encode_contract_bounds( Some(ContractBounds::SingleContractDocumentType { id, document_type_name, - }) => match CString::new(document_type_name.as_str()) { - Ok(value) => Ok((2u8, id.to_buffer(), value.into_raw())), - Err(error) => Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Contract-bounds document type name cannot be represented as a C string: \ - {error}" - ), - )), - }, + }) => { + // The consensus pattern excludes NUL, so a name that passes it is + // always a valid C string. + let value = check_document_type_name(document_type_name) + .map_err(|error| error.to_string()) + .and_then(|()| { + CString::new(document_type_name.as_str()).map_err(|error| error.to_string()) + }) + .map_err(|error| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("Contract-bounds document type: {error}"), + ) + })?; + Ok((2u8, id.to_buffer(), value.into_raw())) + } Some(ContractBounds::ContractGroup { id }) => Ok((3u8, id.to_buffer(), ptr::null_mut())), None => Ok((0u8, [0u8; 32], ptr::null_mut())), } @@ -647,6 +656,52 @@ mod tests { ); } + /// The legacy parser shows a document-type bound in its own field, so a + /// name outside the consensus pattern is refused through the public entry + /// point, as the state transition summary refuses it. + #[test] + fn rejects_contract_bounds_document_type_outside_the_consensus_pattern() { + for name in ["a\nb", "post\u{202e}", "", &"x".repeat(65)] { + let transition: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreationV0 { + id: 17, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + read_only: false, + data: BinaryData::new(vec![0x02; 33]), + signature: BinaryData::new(vec![0xaa; 65]), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([0x44; 32]), + document_type_name: name.to_string(), + }), + } + .into()], + ..Default::default() + } + .into(); + let bytes = transition.serialize_to_bytes().unwrap(); + let mut out = ParsedIdentityUpdateFFI::default(); + + let result = unsafe { + platform_wallet_parse_identity_update_transition( + bytes.as_ptr(), + bytes.len(), + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "{name:?}" + ); + assert!(out.add_public_keys.is_null(), "{name:?}"); + } + } + #[test] fn rejects_truncated_identity_update_transition_bytes() { let mut bytes = fixture_transition_bytes(); 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 1f1ad1c09a3..8e121a95005 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -108,8 +108,8 @@ pub use identity_handle::{ IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use state_transition_summary::{ - decode_state_transition, summarize_state_transition, BatchedTransitionSummary, - BatchedTransitionTarget, DataContractSummary, StateTransitionSummary, + check_document_type_name, decode_state_transition, summarize_state_transition, + BatchedTransitionSummary, BatchedTransitionTarget, DataContractSummary, StateTransitionSummary, StateTransitionSummaryKind, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index 50722ca61ad..eaa9b0350b0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -250,8 +250,9 @@ pub fn decode_state_transition( } /// Document type names reach the approval sheet as their own field, unescaped. Consensus only -/// accepts `^[a-zA-Z0-9-_]{1,64}$`, so anything else is refused here rather than shown. -fn check_document_type_name(name: &str) -> Result<(), PlatformWalletError> { +/// accepts `^[a-zA-Z0-9-_]{1,64}$`, so anything else is refused here rather than shown. Every +/// parser that projects a document type name for approval must apply it. +pub fn check_document_type_name(name: &str) -> Result<(), PlatformWalletError> { let valid = (1..=64).contains(&name.len()) && name .bytes() From 065711181bca8fed4c63fae81a6ebb14afedde9d Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 14 Oct 2026 09:47:09 -0500 Subject: [PATCH 19/20] refactor(platform-wallet): list every transition kind when deciding whether it is signed `carries_signature` fell back to "unsigned" for any kind it did not name, so a witness-authenticated kind added to DPP later would compile and be reported unsigned in every projection. Match every kind explicitly so a new one has to be classified first. Co-Authored-By: Claude Opus 5.5 --- .../network/state_transition_summary.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs index eaa9b0350b0..d97da1c9594 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -329,6 +329,9 @@ fn summarize( /// Whether `transition` carries a non-empty signature or, for the kinds spending platform /// addresses, any input witness; [`StateTransition::signature`] alone is `None` for those. +/// +/// Every kind is listed, so a kind DPP adds later fails to compile here until someone decides +/// how it is authenticated, rather than reporting it unsigned. fn carries_signature(transition: &StateTransition) -> bool { let has_witness = match transition { StateTransition::IdentityCreateFromAddresses(st) => !st.witnesses().is_empty(), @@ -337,7 +340,31 @@ fn carries_signature(transition: &StateTransition) -> bool { StateTransition::AddressFundingFromAssetLock(st) => !st.witnesses().is_empty(), StateTransition::AddressCreditWithdrawal(st) => !st.witnesses().is_empty(), StateTransition::Shield(st) => !st.witnesses().is_empty(), - _ => false, + // Authorized by the top-level signature, or (shielded kinds) by a shielded proof that + // `signature()` does not report. + StateTransition::DataContractCreate(_) + | StateTransition::DataContractUpdate(_) + | StateTransition::Batch(_) + | StateTransition::IdentityCreate(_) + | StateTransition::IdentityTopUp(_) + | StateTransition::IdentityCreditWithdrawal(_) + | StateTransition::IdentityUpdate(_) + | StateTransition::IdentityCreditTransfer(_) + | StateTransition::MasternodeVote(_) + | StateTransition::IdentityCreditTransferToAddresses(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldFromAssetLock(_) + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::ShieldFromIdentity(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) + | StateTransition::IdentityKeyLimitsUpdate(_) + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) + | StateTransition::TokenShieldedTransferWithShieldedFee(_) + | StateTransition::TokenUnshieldWithShieldedFee(_) + | StateTransition::TokenPurchaseFromShieldedPool(_) => false, }; has_witness || transition.signature().is_some_and(|sig| !sig.is_empty()) } From ed34e9e25eb5915e2aa5ccfae5d6f74bd017bd83 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 14 Oct 2026 09:47:09 -0500 Subject: [PATCH 20/20] fix(unified-sdk-jni): overwrite the JVM copy of a connect key when marshalling fails Once the private scalar is copied into a Java byte array, `Zeroizing` only wipes the Rust copy. If building the result array failed afterwards, the JVM copy was left intact and never reached Kotlin's cleanup. Overwrite it before reporting the marshalling error. Co-Authored-By: Claude Opus 5.5 --- packages/rs-unified-sdk-jni/src/identity.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 5051a3bbd54..5602c73c255 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -551,18 +551,25 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive let pubkey = out.public_key_bytes; unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; + let Ok(priv_arr) = env.byte_array_from_slice(&*scalar) else { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "connect key marshalling failed"); + return ptr::null_mut(); + }; 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, 0, &priv_arr)?; env.set_object_array_element(&result, 1, pub_arr)?; Ok(result.into_raw()) })(); build.unwrap_or_else(|_| { let _ = env.exception_clear(); + // `Zeroizing` only wipes the Rust copy; overwrite the JVM copy of + // the private key too, since it never reaches Kotlin's cleanup. + let _ = env.set_byte_array_region(&priv_arr, 0, &[0i8; 32]); throw_sdk_exception(env, 99, "connect key marshalling failed"); ptr::null_mut() })