diff --git a/.github/workflows/pr-review-policy.yml b/.github/workflows/pr-review-policy.yml index 2ab040772fc..2a223c05e7c 100644 --- a/.github/workflows/pr-review-policy.yml +++ b/.github/workflows/pr-review-policy.yml @@ -55,6 +55,6 @@ jobs: (contains(fromJSON('["coderabbitai", "coderabbitai[bot]"]'), github.event.comment.user.login) && (contains(github.event.comment.body, 'final_review_risk_coverage') || contains(github.event.comment.body, 'rate limited by coderabbit.ai'))) - uses: dashpay/stale_prs_are_bad/.github/workflows/pr-review-reusable.yml@5e561c705a09eb80782557d57e905d444b03dffd + uses: dashpay/stale_prs_are_bad/.github/workflows/pr-review-reusable.yml@b9ec4fbd5bbd3ac4bdb164a09f03bff89114fc46 with: scope: ${{ inputs.scope || 'batch' }} diff --git a/packages/rs-platform-version/src/version/mod.rs b/packages/rs-platform-version/src/version/mod.rs index 1b1635efb42..ae5fd0887e1 100644 --- a/packages/rs-platform-version/src/version/mod.rs +++ b/packages/rs-platform-version/src/version/mod.rs @@ -1,6 +1,6 @@ mod protocol_version; -use crate::version::v14::PROTOCOL_VERSION_14; +use crate::version::v15::PROTOCOL_VERSION_15; pub use protocol_version::*; use std::ops::RangeInclusive; @@ -20,6 +20,7 @@ pub mod v11; pub mod v12; pub mod v13; pub mod v14; +pub mod v15; pub mod v2; pub mod v3; pub mod v4; @@ -33,5 +34,5 @@ pub type ProtocolVersion = u32; pub const ALL_VERSIONS: RangeInclusive = 1..=LATEST_VERSION; -pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_14; +pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_15; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..d6273f0e766 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -22,6 +22,7 @@ use crate::version::v11::PLATFORM_V11; use crate::version::v12::PLATFORM_V12; use crate::version::v13::PLATFORM_V13; use crate::version::v14::PLATFORM_V14; +use crate::version::v15::PLATFORM_V15; use crate::version::v2::PLATFORM_V2; use crate::version::v3::PLATFORM_V3; use crate::version::v4::PLATFORM_V4; @@ -61,6 +62,7 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V12, PLATFORM_V13, PLATFORM_V14, + PLATFORM_V15, ]; #[cfg(feature = "mock-versions")] @@ -69,7 +71,7 @@ pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::ne #[cfg(feature = "mock-versions")] const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; -pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; +pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V15; pub const DESIRED_PLATFORM_VERSION: &PlatformVersion = LATEST_PLATFORM_VERSION; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs new file mode 100644 index 00000000000..e49b5133ace --- /dev/null +++ b/packages/rs-platform-version/src/version/v15.rs @@ -0,0 +1,71 @@ +use crate::version::consensus_versions::ConsensusVersions; +use crate::version::dpp_versions::dpp_asset_lock_versions::v1::DPP_ASSET_LOCK_VERSIONS_V1; +use crate::version::dpp_versions::dpp_contract_versions::v6::CONTRACT_VERSIONS_V6; +use crate::version::dpp_versions::dpp_costs_versions::v1::DPP_COSTS_VERSIONS_V1; +use crate::version::dpp_versions::dpp_document_versions::v4::DOCUMENT_VERSIONS_V4; +use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS_V1; +use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; +use crate::version::dpp_versions::dpp_method_versions::v3::DPP_METHOD_VERSIONS_V3; +use crate::version::dpp_versions::dpp_state_transition_conversion_versions::v2::STATE_TRANSITION_CONVERSION_VERSIONS_V2; +use crate::version::dpp_versions::dpp_state_transition_method_versions::v2::STATE_TRANSITION_METHOD_VERSIONS_V2; +use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v3::STATE_TRANSITION_SERIALIZATION_VERSIONS_V3; +use crate::version::dpp_versions::dpp_state_transition_versions::v4::STATE_TRANSITION_VERSIONS_V4; +use crate::version::dpp_versions::dpp_token_versions::v3::TOKEN_VERSIONS_V3; +use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VERSIONS_V5; +use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; +use crate::version::dpp_versions::DPPVersion; +use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; +use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; +use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; +use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; +use crate::version::drive_abci_versions::DriveAbciVersion; +use crate::version::drive_versions::v9::DRIVE_VERSION_V9; +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::protocol_version::PlatformVersion; +use crate::version::system_data_contract_versions::v3::SYSTEM_DATA_CONTRACT_VERSIONS_V3; +use crate::version::system_limits::v4::SYSTEM_LIMITS_V4; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; + +/// Introduced as the activation gate for the consensus changes of the 4.3 +/// line. Functionally identical to v14 at introduction: the same component +/// version structs, no behavior change. Each change that needs this gate +/// lands in its own follow-up and bumps the component table it consumes here; +/// keeping v15 == v14 until then lets mixed-version validators agree. +pub const PLATFORM_V15: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_15, + drive: DRIVE_VERSION_V9, + drive_abci: DriveAbciVersion { + structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, + withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, + query: DRIVE_ABCI_QUERY_VERSIONS_V3, + checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, + }, + dpp: DPPVersion { + costs: DPP_COSTS_VERSIONS_V1, + validation: DPP_VALIDATION_VERSIONS_V5, + state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, + state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, + state_transitions: STATE_TRANSITION_VERSIONS_V4, + contract_versions: CONTRACT_VERSIONS_V6, + document_versions: DOCUMENT_VERSIONS_V4, + identity_versions: IDENTITY_VERSIONS_V1, + voting_versions: VOTING_VERSION_V2, + token_versions: TOKEN_VERSIONS_V3, + asset_lock_versions: DPP_ASSET_LOCK_VERSIONS_V1, + methods: DPP_METHOD_VERSIONS_V3, + factory_versions: DPP_FACTORY_VERSIONS_V1, + }, + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, + fee_version: FEE_VERSION3, + system_limits: SYSTEM_LIMITS_V4, + consensus: ConsensusVersions { + tenderdash_consensus_version: 1, + }, +}; diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 5e7bc342baf..5e1abe6593a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -977,6 +977,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockNotTracked(..) => { PlatformWalletFFIResultCode::ErrorAssetLockNotTracked } + // A DashPay invitation link for the other network: definitive, so + // hosts can say so instead of treating it as undetermined. + PlatformWalletError::InvitationNetworkMismatch { .. } => { + PlatformWalletFFIResultCode::ErrorInvalidNetwork + } PlatformWalletError::AssetLockAlreadyConsumed(..) => { PlatformWalletFFIResultCode::ErrorAssetLockAlreadyConsumed } diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 089e95bc6c5..bc258dd02ea 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -30,12 +30,12 @@ use std::os::raw::c_char; use dpp::identity::accessors::IdentityGettersV0; use platform_wallet::wallet::identity::crypto::{ - parse_invitation_uri, wif_network_matches, InviterInfo, + parse_invitation_uri, InviterInfo, ParsedInvitation, }; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use platform_wallet::wallet::identity::network::{ - MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, + InvitationClaimStatus, MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, }; use crate::core_wallet_types::OutPointFFI; @@ -336,6 +336,19 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( PlatformWalletFFIResult::ok() } +/// Parse a link for one of the read-only invitation queries. A malformed link +/// is definitive, not undetermined: there is no invitation to claim, so it is +/// its own code (`ErrorInvalidParameter`) and the caller can say so instead of +/// falling through the generic arm into "proceed anyway". +fn parse_invitation_for_query(uri: &str) -> Result { + parse_invitation_uri(uri).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invitation link is malformed and cannot be claimed: {e}"), + ) + }) +} + /// The identity id this invitation WOULD create — a read-only probe that lets /// the UI reject an already-claimed voucher up front. /// @@ -369,8 +382,7 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( /// /// * `ErrorInvalidParameter` — the URI is malformed, so there is no invitation. /// * `ErrorInvalidNetwork` — the voucher key belongs to the other network; -/// [`platform_wallet_claim_invitation`] applies the same guard and will -/// refuse it too. +/// [`platform_wallet_claim_invitation`] refuses it with the same code. /// /// Every other failure (funding-tx not yet propagated, transport error) leaves /// usability genuinely undetermined, and only those should be treated as @@ -385,60 +397,125 @@ pub unsafe extern "C" fn platform_wallet_invitation_prospective_identity_id( uri: *const c_char, out_identity_id: *mut [u8; 32], ) -> PlatformWalletFFIResult { - check_ptr!(uri); check_ptr!(out_identity_id); // Sentinel before any fallible work, matching the claim/parse siblings. unsafe { *out_identity_id = [0u8; 32]; } + check_ptr!(uri); let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()); - // A malformed link is definitive, not undetermined: there is no invitation - // to claim. Surfaced as its own code so the caller can say so instead of - // falling through the generic arm into "proceed anyway". - let invitation = match parse_invitation_uri(uri) { + let invitation = match parse_invitation_for_query(uri) { Ok(invitation) => invitation, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("invitation link is malformed and cannot be claimed: {e}"), - ); - } + Err(e) => return e, }; - let option = PLATFORM_WALLET_STORAGE.with_item( - wallet_handle, - |wallet| -> Result { - // Also definitive: the claim applies the same guard, so a link for the - // other network can never be claimed through this wallet. Checked here - // (as the withdrawal FFI does) to give it a distinguishable code rather - // than flattening into the catch-all the library error maps to. - if !wif_network_matches(invitation.voucher_key_network, wallet.network()) { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidNetwork, - format!( - "invitation is for the {:?} network but this wallet is on {:?}", - invitation.voucher_key_network, - wallet.network() - ), - )); - } - let identity_wallet = wallet.identity().clone(); - block_on_worker(async move { - identity_wallet - .invitation_prospective_identity_id(&invitation) - .await - }) - .map_err(PlatformWalletFFIResult::from) - }, - ); + // A link for the other network comes back as the library's typed + // mismatch, which maps to `ErrorInvalidNetwork`. + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + identity_wallet + .invitation_prospective_identity_id(&invitation) + .await + }) + }); let result = unwrap_option_or_return!(option); - let identifier = match result { - Ok(identifier) => identifier, + let identifier = unwrap_result_or_return!(result); + unsafe { + *out_identity_id = identifier.to_buffer(); + } + PlatformWalletFFIResult::ok() +} + +/// The invitee's pre-claim view of an invitation (see +/// [`platform_wallet_invitation_claim_status`]). +#[repr(C)] +pub struct InvitationClaimStatusFFI { + /// The identity the claim would create (derived from the credit outpoint). + pub prospective_identity_id: [u8; 32], + /// Value of the credit output the voucher key controls (duffs) — the + /// tier signal (the link does not say whether it funds a contested name). + pub amount_duffs: u64, + /// The claim would submit an InstantSend proof. + pub is_instant: bool, + /// The funding transaction is chain-locked. + pub is_chain_locked: bool, + /// An identity already exists at `prospective_identity_id`: the invitation + /// was claimed. `false` does NOT prove the voucher is unspent (see + /// [`platform_wallet_invitation_prospective_identity_id`]). + pub already_claimed: bool, +} + +impl InvitationClaimStatusFFI { + fn zeroed() -> Self { + Self { + prospective_identity_id: [0u8; 32], + amount_duffs: 0, + is_instant: false, + is_chain_locked: false, + already_claimed: false, + } + } +} + +impl From for InvitationClaimStatusFFI { + fn from(status: InvitationClaimStatus) -> Self { + Self { + prospective_identity_id: status.prospective_identity_id.to_buffer(), + amount_duffs: status.amount_duffs, + is_instant: status.is_instant, + is_chain_locked: status.is_chain_locked, + already_claimed: status.already_claimed, + } + } +} + +/// What an invitation is worth and whether it was already claimed, without +/// claiming it: one funding-tx fetch (with the claim's propagation retry) and +/// one identity fetch. A ChainLock-only link whose funding tx is not +/// chain-locked yet is reported (`is_instant` and `is_chain_locked` both +/// false) rather than refused. +/// +/// Same error contract as +/// [`platform_wallet_invitation_prospective_identity_id`]: +/// +/// * `ErrorInvalidParameter` — the URI is malformed (definitive). +/// * `ErrorInvalidNetwork` — the voucher key belongs to the other network +/// (definitive). +/// * anything else — undetermined (not propagated yet, transport error). +/// +/// `out_status` is zeroed before any fallible work. +/// +/// # Safety +/// - `uri` must be a valid NUL-terminated UTF-8 C string. +/// - `out_status` must be a valid `*mut InvitationClaimStatusFFI`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_invitation_claim_status( + wallet_handle: Handle, + uri: *const c_char, + out_status: *mut InvitationClaimStatusFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_status); + unsafe { + *out_status = InvitationClaimStatusFFI::zeroed(); + } + check_ptr!(uri); + + let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()); + let invitation = match parse_invitation_for_query(uri) { + Ok(invitation) => invitation, Err(e) => return e, }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { identity_wallet.invitation_claim_status(&invitation).await }) + }); + let result = unwrap_option_or_return!(option); + let status = unwrap_result_or_return!(result); unsafe { - *out_identity_id = identifier.to_buffer(); + *out_status = status.into(); } PlatformWalletFFIResult::ok() } @@ -473,11 +550,19 @@ pub struct InvitationPreviewFFI { /// [`crate::platform_wallet_string_free`]. pub inviter_username: *mut c_char, /// Amount locked in the voucher (duffs) — always 0: unknown pre-fetch (the - /// link carries the funding txid, not the proof), resolved at claim time. + /// link carries the funding txid, not the proof). Read it with + /// [`platform_wallet_invitation_claim_status`]. pub amount_duffs: u64, /// Advisory expiry (unix seconds) — always 0: the legacy link carries no /// expiry field. pub expiry_unix: u32, + /// Inviter display name (`display-name`) — heap C string, or null when the + /// link carried none. Free with [`crate::platform_wallet_string_free`]. + pub inviter_display_name: *mut c_char, + /// Inviter avatar URL (`avatar-url`, already percent-decoded) — heap C + /// string, or null when the link carried none. Free with + /// [`crate::platform_wallet_string_free`]. + pub inviter_avatar_url: *mut c_char, } impl InvitationPreviewFFI { @@ -492,10 +577,22 @@ impl InvitationPreviewFFI { inviter_username: std::ptr::null_mut(), amount_duffs: 0, expiry_unix: 0, + inviter_display_name: std::ptr::null_mut(), + inviter_avatar_url: std::ptr::null_mut(), } } } +/// Heap C string for an optional link field, or null when absent. An interior +/// NUL cannot come out of a percent-decoded query value we accept, but falls +/// back to null rather than failing the whole preview. +fn optional_c_string(value: Option<&String>) -> *mut c_char { + value + .and_then(|v| std::ffi::CString::new(v.clone()).ok()) + .map(|c| c.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + /// Decode a `dashpay://invite?…` link into a read-only /// [`InvitationPreviewFFI`] — NO claim, NO network, NO wallet handle. /// @@ -504,8 +601,9 @@ impl InvitationPreviewFFI { /// the UI can render a clean "invalid invitation" state; only a null / non-UTF-8 /// `uri` argument returns an error result. /// -/// When `out_preview.inviter_username` is non-null it is a heap C string the -/// caller frees with [`crate::platform_wallet_string_free`]. It can be null +/// `out_preview.inviter_username`, `inviter_display_name` and +/// `inviter_avatar_url` are each either null or a heap C string the caller +/// frees with [`crate::platform_wallet_string_free`]. The username can be null /// even when `has_inviter` is set (a metadata-only link) — see the field docs. /// /// # Safety @@ -540,23 +638,26 @@ pub unsafe extern "C" fn platform_wallet_parse_invitation( let is_instant = parsed.islock_hex.is_some(); let amount_duffs = 0; - let (has_inviter, inviter_id, inviter_username) = match parsed.inviter.as_ref() { - Some(info) => { - // Username is absent for a metadata-only (du-less) link; an interior - // NUL can't occur in a decoded UTF-8 DPNS label, but fall back to a - // null username rather than fail the whole preview. - let username = info - .username - .as_ref() - .and_then(|u| std::ffi::CString::new(u.clone()).ok()) - .map(|c| c.into_raw()) - .unwrap_or(std::ptr::null_mut()); - // The link has no inviter identity id (resolved from the username via - // DPNS at contact-bootstrap); report zeros. - (true, [0u8; 32], username) - } - None => (false, [0u8; 32], std::ptr::null_mut()), - }; + let (has_inviter, inviter_id, inviter_username, inviter_display_name, inviter_avatar_url) = + match parsed.inviter.as_ref() { + // Username is absent for a metadata-only (du-less) link. The link + // has no inviter identity id (resolved from the username via DPNS + // at contact-bootstrap); report zeros. + Some(info) => ( + true, + [0u8; 32], + optional_c_string(info.username.as_ref()), + optional_c_string(info.display_name.as_ref()), + optional_c_string(info.avatar_url.as_ref()), + ), + None => ( + false, + [0u8; 32], + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ), + }; unsafe { *out_preview = InvitationPreviewFFI { @@ -568,6 +669,8 @@ pub unsafe extern "C" fn platform_wallet_parse_invitation( amount_duffs, // The link carries no expiry (legacy format); 0 ⇒ "no expiry". expiry_unix: 0, + inviter_display_name, + inviter_avatar_url, }; } PlatformWalletFFIResult::ok() @@ -818,6 +921,94 @@ mod tests { assert!(preview.inviter_username.is_null()); } + /// The inviter's display name and avatar URL travel through the preview + /// (percent-decoded), and both are null for a link that carries neither. + #[test] + fn should_surface_inviter_display_name_and_avatar_in_the_preview() { + let key = SecretKey::from_slice(&[0x11u8; 32]).unwrap(); + let wif = PrivateKey::new(key, Network::Testnet).to_wif(); + let txid = "ab".repeat(32); + let take = |ptr: *mut c_char| -> Option { + if ptr.is_null() { + return None; + } + let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string(); + unsafe { crate::platform_wallet_string_free(ptr) }; + Some(s) + }; + + let with_meta = std::ffi::CString::new(format!( + "dashpay://invite?du=alice&assetlocktx={txid}&pk={wif}&islock=null\ + &display-name=Alice%20B&avatar-url=https%3A%2F%2Fexample.org%2Fa.png" + )) + .unwrap(); + let mut preview = InvitationPreviewFFI::invalid(); + let r = unsafe { platform_wallet_parse_invitation(with_meta.as_ptr(), &mut preview) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert!(preview.structurally_valid); + assert_eq!(take(preview.inviter_username).as_deref(), Some("alice")); + assert_eq!( + take(preview.inviter_display_name).as_deref(), + Some("Alice B") + ); + assert_eq!( + take(preview.inviter_avatar_url).as_deref(), + Some("https://example.org/a.png") + ); + + let bare = std::ffi::CString::new(format!( + "dashpay://invite?du=alice&assetlocktx={txid}&pk={wif}&islock=null" + )) + .unwrap(); + let mut preview = InvitationPreviewFFI::invalid(); + let r = unsafe { platform_wallet_parse_invitation(bare.as_ptr(), &mut preview) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert_eq!(take(preview.inviter_username).as_deref(), Some("alice")); + assert!(preview.inviter_display_name.is_null()); + assert!(preview.inviter_avatar_url.is_null()); + } + + /// A malformed link is a definitive `ErrorInvalidParameter` from the status + /// call — never a zeroed "not claimed" the caller could proceed on. + #[test] + fn should_refuse_a_malformed_link_in_claim_status_as_invalid_parameter() { + let bad = std::ffi::CString::new("https://not-an-invite").unwrap(); + let mut status = InvitationClaimStatusFFI::zeroed(); + status.amount_duffs = 7; + let r = unsafe { platform_wallet_invitation_claim_status(0, bad.as_ptr(), &mut status) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + assert_eq!(status.amount_duffs, 0, "the out-param is zeroed first"); + } + + /// A null `uri` is rejected with `ErrorNullPointer`, after the out-param + /// is zeroed. + #[test] + fn should_refuse_a_null_uri_in_claim_status_and_still_zero_the_status() { + let mut status = InvitationClaimStatusFFI::zeroed(); + status.already_claimed = true; + let r = + unsafe { platform_wallet_invitation_claim_status(0, std::ptr::null(), &mut status) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!(!status.already_claimed, "the out-param is zeroed first"); + } + + /// A link for the other network is refused by the library with a typed + /// error that must reach hosts as the definitive `ErrorInvalidNetwork`, + /// not the undetermined catch-all. + #[test] + fn should_map_an_invitation_network_mismatch_to_invalid_network() { + let result = PlatformWalletFFIResult::from( + platform_wallet::PlatformWalletError::InvitationNetworkMismatch { + invitation: Network::Testnet, + wallet: Network::Mainnet, + }, + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidNetwork + ); + } + /// The amount-bound getters hand back the library constants verbatim. This /// bridge exists precisely so clients stop mirroring the values, so a drift /// here would silently reinstate the mirror it replaced. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index d87b3f1ba58..ee3307e4623 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -67,6 +67,15 @@ pub enum PlatformWalletError { #[error("Invalid identity data: {0}")] InvalidIdentityData(String), + /// A DashPay invitation link's voucher key is for the other network (a + /// testnet link opened in a mainnet wallet, or the reverse). Definitive: + /// the claim refuses the link before any network work. + #[error("invitation is for the {invitation:?} network but this wallet is on {wallet:?}")] + InvitationNetworkMismatch { + invitation: Network, + wallet: Network, + }, + #[error("Failed to persist state: {0}")] /// A persister `store(...)` round failed. Returned (not swallowed) by /// user-initiated writes whose loss leaves a silent, non-self-healing diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 6060013024a..ce3ba5023ce 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -43,7 +43,7 @@ use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dashcore::transaction::special_transaction::TransactionPayload; -use dashcore::{Network, PrivateKey, ScriptBuf, Transaction}; +use dashcore::{Network, PrivateKey, ScriptBuf, Transaction, TxOut}; use dpp::prelude::AssetLockProof; use crate::error::PlatformWalletError; @@ -514,6 +514,15 @@ pub fn voucher_output_index( transaction: &Transaction, voucher_key: &SecretKey, ) -> Result { + voucher_credit_output(transaction, voucher_key).map(|(index, _)| index) +} + +/// [`voucher_output_index`] plus the selected credit output itself, so a +/// caller that needs the voucher's value reads it from the same match. +pub fn voucher_credit_output<'a>( + transaction: &'a Transaction, + voucher_key: &SecretKey, +) -> Result<(u32, &'a TxOut), PlatformWalletError> { let Some(TransactionPayload::AssetLockPayloadType(payload)) = &transaction.special_transaction_payload else { @@ -525,8 +534,9 @@ pub fn voucher_output_index( payload .credit_outputs .iter() - .position(|out| out.script_pubkey == expected) - .map(|idx| idx as u32) + .enumerate() + .find(|(_, out)| out.script_pubkey == expected) + .map(|(index, out)| (index as u32, out)) .ok_or_else(|| { invalid("voucher key does not control any credit output of the funding transaction") }) @@ -768,6 +778,18 @@ mod tests { assert_eq!(voucher_output_index(&tx, &key).unwrap(), 2); } + /// The credit output comes back with its index, so its value is the + /// voucher's, not a decoy's. + #[test] + fn should_return_the_voucher_credit_output_with_its_index() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 2); + let (index, output) = voucher_credit_output(&tx, &key).unwrap(); + assert_eq!(index, 2); + assert_eq!(output.value, 100_000, "the decoys before it carry 50_000"); + assert_eq!(output.script_pubkey, voucher_credit_script(&key)); + } + #[test] fn voucher_output_index_rejects_no_match() { let key = voucher(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index c0a0687b44b..8164e130b0b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -19,7 +19,7 @@ pub use dip14::{ derive_contact_xpub, unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; pub use invitation::{ - encode_invitation_uri, parse_invitation_uri, voucher_output_index, wif_network_matches, - InviterInfo, ParsedInvitation, + encode_invitation_uri, parse_invitation_uri, voucher_credit_output, voucher_output_index, + wif_network_matches, InviterInfo, ParsedInvitation, }; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index 965742e2c45..b6aa7875125 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -541,7 +541,7 @@ impl DashPayView<'_, B> { let dashpay_contract = super::dashpay_contract()?; // 1. Local state first — works offline and feeds SwiftData. - let (established_count, identity_index, signing_key, root_key_id) = { + let (established_count, identity_index, identity, root_key_id) = { let mut wm = self.wallet_manager.write().await; let info = wm .get_wallet_info_mut(&self.wallet_id) @@ -564,14 +564,8 @@ impl DashPayView<'_, B> { } let established_count = managed.dashpay().established_contacts().len(); let identity_index = managed.identity_index; - let signing_key = super::usable_authentication_key( - &managed.identity, - dashpay_contract.id(), - "contactInfo", - &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], - &[KeyType::ECDSA_SECP256K1], - ) - .cloned(); + // The signing key is chosen below, after the lock: signer callbacks must not hold it. + let identity = managed.identity.clone(); // Shared own-ECDH-root selector (same policy as the // contact-request send path); `Option` preserved — a missing // key defers the publish rather than erroring here. @@ -581,7 +575,7 @@ impl DashPayView<'_, B> { ) .ok() .map(|k| k.id()); - (established_count, identity_index, signing_key, root_key_id) + (established_count, identity_index, identity, root_key_id) }; // 2. DIP-15 privacy gate. @@ -602,7 +596,16 @@ impl DashPayView<'_, B> { ); return Ok(ContactInfoPublishOutcome::SkippedWatchOnly); }; - let signing_key = signing_key.ok_or_else(|| { + let signing_key = super::usable_authentication_key( + &identity, + signer, + dashpay_contract.id(), + "contactInfo", + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], + )? + .cloned() + .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "No HIGH or CRITICAL authentication key found on identity \ (required for document state transitions)" diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 8f95fed9279..753cf5db9ff 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1,5 +1,6 @@ //! DashPay contact request lifecycle: send, sync, accept, reject. +use super::signing_key::AvailableSigningKey; use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -610,12 +611,13 @@ impl DashPayView<'_, B> { // Contact-request send writes a document state transition, // which DPP requires to be signed by a HIGH-or-stricter // authentication key. MASTER is rejected on document writes. - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs index 11a42a37ab0..ffa31f95316 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs @@ -26,6 +26,7 @@ //! the rs-sdk-ffi path — classic stack-guard fingerprint, not //! memory unsafety. +use super::signing_key::AvailableSigningKey; use async_trait::async_trait; use dpp::address_funds::AddressWitness; @@ -148,7 +149,6 @@ impl IdentityWallet { S: Signer + Send + Sync, { // 1. Owner identity + signing key from the wallet manager. - use dpp::identity::accessors::IdentityGettersV0; let signing_key = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { @@ -161,17 +161,19 @@ impl IdentityWallet { .identity(owner_identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + drop(wm); // Contract create requires CRITICAL + AUTHENTICATION + // ECDSA_SECP256K1 specifically — DPP rejects HIGH / // MEDIUM / non-ECDSA keys on this state-transition // shape. identity - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - [SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), + &[SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "No CRITICAL authentication key found on owner identity \ @@ -372,7 +374,6 @@ impl IdentityWallet { S: Signer + Send + Sync, { // 1. Owner identity + signing key from the wallet manager. - use dpp::identity::accessors::IdentityGettersV0; let signing_key = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { @@ -385,17 +386,19 @@ impl IdentityWallet { .identity(owner_identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + drop(wm); // Contract update requires the same CRITICAL + // AUTHENTICATION + ECDSA_SECP256K1 key as create — DPP // rejects HIGH / MEDIUM / non-ECDSA keys on this // state-transition shape. identity - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - [SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), + &[SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "No CRITICAL authentication key found on owner identity \ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index c0409a1e3f5..308043892ad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -26,6 +26,7 @@ //! stack-overflow avoidance `contract.rs` documents for the //! post-broadcast GroveDB proof-verification recursion. +use super::signing_key::{require_available, AvailableSigningKey}; use std::collections::BTreeMap; use std::sync::Arc; @@ -275,13 +276,15 @@ impl IdentityWallet { .identity(owner_identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + drop(wm); identity - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - allowed_levels.iter().copied().collect(), - [KeyType::ECDSA_SECP256K1].into(), + &allowed_levels, + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .ok_or_else(|| { PlatformWalletError::InvalidIdentityData(format!( "No ECDSA authentication key at a security level satisfying \ @@ -415,6 +418,7 @@ impl IdentityWallet { &self, owner_identity_id: &Identifier, signing_key_id: u32, + signer: &impl Signer, ) -> Result { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { @@ -427,6 +431,7 @@ impl IdentityWallet { .identity(owner_identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + drop(wm); let key = identity .get_public_key_by_id(signing_key_id) .ok_or_else(|| { @@ -449,6 +454,7 @@ impl IdentityWallet { key.key_type() ))); } + require_available(&key, signer)?; Ok(key) } @@ -522,7 +528,7 @@ impl IdentityWallet { })?; let signing_key = self - .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .resolve_authentication_signing_key(owner_identity_id, signing_key_id, signer) .await?; let builder = DocumentReplaceTransitionBuilder::new( @@ -571,7 +577,7 @@ impl IdentityWallet { .await?; let signing_key = self - .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .resolve_authentication_signing_key(owner_identity_id, signing_key_id, signer) .await?; // Delete is keyed by (document_id, owner_id); no current-document @@ -635,7 +641,7 @@ impl IdentityWallet { })?; let signing_key = self - .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .resolve_authentication_signing_key(owner_identity_id, signing_key_id, signer) .await?; let builder = DocumentTransferTransitionBuilder::new( @@ -698,7 +704,7 @@ impl IdentityWallet { })?; let signing_key = self - .resolve_authentication_signing_key(owner_identity_id, signing_key_id) + .resolve_authentication_signing_key(owner_identity_id, signing_key_id, signer) .await?; let builder = DocumentSetPriceTransitionBuilder::new( @@ -764,7 +770,7 @@ impl IdentityWallet { })?; let signing_key = self - .resolve_authentication_signing_key(purchaser_identity_id, signing_key_id) + .resolve_authentication_signing_key(purchaser_identity_id, signing_key_id, signer) .await?; let builder = DocumentPurchaseTransitionBuilder::new( @@ -797,8 +803,25 @@ impl IdentityWallet { #[cfg(test)] mod tests { + use super::super::signing_key::tests::{lock_checking_signer, wallet_with_signing_keys}; use super::*; + #[tokio::test] + async fn should_reject_unavailable_explicit_document_key_outside_wallet_lock() { + let wallet = wallet_with_signing_keys().await; + let signer = lock_checking_signer(wallet.identity()); + let error = wallet + .identity() + .resolve_authentication_signing_key(&Identifier::default(), 1, &signer) + .await + .unwrap_err(); + assert!( + matches!(error, PlatformWalletError::Sdk(dash_sdk::Error::Protocol(dpp::ProtocolError::Generic(ref message))) + if message.starts_with(crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX)), + "{error:?}" + ); + } + #[test] fn allowed_levels_high_requirement_admits_critical_and_high_only() { // DPNS `preorder` requires HIGH: CRITICAL + HIGH qualify, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index 86f0b809bb2..7af68cc404b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -1,6 +1,6 @@ //! DPNS name registration, resolution, search, and contest queries. -use dpp::identity::accessors::IdentityGettersV0; +use super::signing_key::AvailableSigningKey; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; @@ -206,6 +206,7 @@ impl IdentityWallet { .identity(identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + drop(wm); // DPNS name registration writes a document state transition, // which DPP requires to be signed by a HIGH-or-stricter // authentication key. MASTER is intentionally excluded — @@ -214,12 +215,13 @@ impl IdentityWallet { // rejected by the protocol on document-side state // transitions. let key = identity - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "No HIGH or CRITICAL authentication key found on identity \ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 603aff74b8c..d0a1993e4fe 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -26,6 +26,7 @@ //! purchase/transfer; a name inside an active contested-name vote is not //! in the documents tree at all. +use super::signing_key::AvailableSigningKey; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::sync::Arc; @@ -903,6 +904,7 @@ impl IdentityWallet { async fn select_dpns_signing_key( &self, identity_id: &Identifier, + signer: &impl Signer, ) -> Result { let contract = self.dpns_contract().await?; let required_level = contract @@ -925,13 +927,15 @@ impl IdentityWallet { .wallet_identity(&self.wallet_id, identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + drop(wm); identity - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - allowed_levels.iter().copied().collect(), - [KeyType::ECDSA_SECP256K1].into(), + &allowed_levels, + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData(format!( @@ -1099,7 +1103,9 @@ impl IdentityWallet { state.owner_id ))); } - let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let signing_key = self + .select_dpns_signing_key(owner_identity_id, signer) + .await?; let contract_id = dpns_contract_id(); let confirmed = self .set_document_price_with_signer( @@ -1151,7 +1157,9 @@ impl IdentityWallet { document_id: state.document_id, }); } - let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let signing_key = self + .select_dpns_signing_key(owner_identity_id, signer) + .await?; let contract_id = dpns_contract_id(); let confirmed = self .transfer_document_with_signer( @@ -1212,7 +1220,9 @@ impl IdentityWallet { state.owner_id ))); } - let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let signing_key = self + .select_dpns_signing_key(owner_identity_id, signer) + .await?; let contract_id = dpns_contract_id(); let confirmed = self .transfer_document_with_signer( @@ -1332,7 +1342,9 @@ impl IdentityWallet { available, }); } - let signing_key = self.select_dpns_signing_key(purchaser_identity_id).await?; + let signing_key = self + .select_dpns_signing_key(purchaser_identity_id, signer) + .await?; let contract_id = dpns_contract_id(); let confirmed = self .purchase_document_with_signer( @@ -2216,7 +2228,11 @@ fn required_purchase_credits(expected_price: Credits) -> Result) -> std::fmt::Result { @@ -426,21 +449,49 @@ impl IdentityWallet { &self, invitation: &ParsedInvitation, ) -> Result { - if !wif_network_matches(invitation.voucher_key_network, self.sdk.network) { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "invitation is for the {:?} network but this wallet is on {:?}", - invitation.voucher_key_network, self.sdk.network - ))); - } - let proof = self.reconstruct_asset_lock_proof(invitation).await?; - // The reconstruction now carries an optional ChainLock fallback for - // the claim path; the prospective id is always derived from the - // PRIMARY proof (both proofs cover the same credit output, so the - // id is identical either way). - proof.primary.create_identifier().map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "invitation asset lock proof yielded no identity id: {e}" - )) + let funding = self.inspect_invitation_funding(invitation).await?; + Ok(identity_id_for_out_point(funding.out_point)) + } + + /// What an invitation is worth and whether it was already claimed — the + /// invitee's pre-claim check, one network round trip before any username + /// is picked. + /// + /// The amount is the tier signal: the link does not say whether it funds a + /// contested or a non-contested username, the value of the voucher's credit + /// output does (the inviter chose it). It is read from the same refetched + /// funding transaction the claim uses, so it is exactly what the claim + /// would spend. + /// + /// `already_claimed` carries the one-way caveat of + /// [`Self::invitation_prospective_identity_id`]: `true` is definitive, + /// `false` does not prove the voucher is unspent (a reclaim top-up consumes + /// it without creating the derived identity). + /// + /// It describes the funding transaction rather than requiring a buildable + /// proof: a ChainLock-only link whose funding tx is not chain-locked yet + /// reports `is_instant == false, is_chain_locked == false` and its amount + /// instead of failing (the claim of such a link waits for the ChainLock). + /// A failed identity lookup still fails the whole call. + /// + /// Costs one funding-tx fetch (with the claim's bounded propagation retry) + /// and one identity fetch. Same wrong-network fail-fast as the claim. + pub async fn invitation_claim_status( + &self, + invitation: &ParsedInvitation, + ) -> Result { + let funding = self.inspect_invitation_funding(invitation).await?; + let prospective_identity_id = identity_id_for_out_point(funding.out_point); + let already_claimed = Identity::fetch(&self.sdk, prospective_identity_id) + .await + .map_err(PlatformWalletError::Sdk)? + .is_some(); + Ok(InvitationClaimStatus { + prospective_identity_id, + amount_duffs: funding.credit_output_duffs, + is_instant: funding.instant_lock.is_some(), + is_chain_locked: funding.is_chain_locked, + already_claimed, }) } @@ -491,16 +542,7 @@ impl IdentityWallet { S: Signer + Send + Sync, { preflight_keys_map(&keys_map)?; - - // Reject a wrong-network link before any network work: a testnet WIF is a - // valid key on the wrong chain, so it would otherwise surface as a - // confusing funding-tx fetch miss rather than a clear "wrong network". - if !wif_network_matches(invitation.voucher_key_network, self.sdk.network) { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "invitation is for the {:?} network but this wallet is on {:?}", - invitation.voucher_key_network, self.sdk.network - ))); - } + ensure_invitation_network(&invitation, self.sdk.network)?; // Reconstruct the funding asset-lock proof by refetching the tx. Consensus // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local @@ -516,6 +558,11 @@ impl IdentityWallet { primary, chain_fallback, } = self.reconstruct_asset_lock_proof(&invitation).await?; + // Taken before `primary` moves into the submission: the outpoint this + // claim spends and the identity it creates, for recognizing our own + // earlier claim below. + let out_point = out_point_from_proof(&primary); + let prospective_identity_id = identity_id_for_out_point(out_point); // The voucher key signs the asset lock's outer ST signature (ECDSA over // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, @@ -546,7 +593,7 @@ impl IdentityWallet { let sdk = &self.sdk; let placeholder = &placeholder; let voucher_priv = &voucher_priv; - let identity = + let submitted = submit_claim_with_stale_islock_fallback(primary, chain_fallback, move |proof| { submit_with_cl_height_retry(settings, move |s| { placeholder.put_to_platform_and_wait_for_response_with_private_key( @@ -558,6 +605,17 @@ impl IdentityWallet { ) }) }) + .await; + + // A lost-ACK duplicate of this very submission ("already in the + // mempool / in chain") is resolved inside the SDK's result wait. What + // reaches here is a claim repeated after an earlier one landed, refused + // because its outpoint is consumed: adopt the identity when it carries + // exactly the keys we submitted. + let identity = + adopt_already_landed_claim(submitted, &out_point, placeholder.public_keys(), || { + Identity::fetch(sdk, prospective_identity_id) + }) .await?; // Best-effort local bookkeeping — Platform has already accepted the @@ -640,8 +698,36 @@ impl IdentityWallet { &self, invitation: &ParsedInvitation, ) -> Result { + let fetched = self.fetch_funding_tx(invitation).await?; + assemble_asset_lock_proof( + fetched.transaction, + fetched.is_chain_locked, + fetched.height, + invitation, + ) + } + + /// The wrong-network guard, the funding-tx fetch and its inspection: what + /// the invitee's read-only queries need, without requiring the tx to be + /// claimable yet (see [`inspect_funding_tx`]). + async fn inspect_invitation_funding( + &self, + invitation: &ParsedInvitation, + ) -> Result { + ensure_invitation_network(invitation, self.sdk.network)?; + let fetched = self.fetch_funding_tx(invitation).await?; + inspect_funding_tx(&fetched.transaction, fetched.is_chain_locked, invitation) + } + + /// Fetch the invitation's funding transaction with the bounded propagation + /// retry (see [`fetch_funding_tx_with_retry`]); a miss after every attempt + /// is an error. + async fn fetch_funding_tx( + &self, + invitation: &ParsedInvitation, + ) -> Result { let sdk = &self.sdk; - let fetched = fetch_funding_tx_with_retry( + fetch_funding_tx_with_retry( &invitation.funding_txid, |txid| async move { sdk.get_transaction(&txid) @@ -658,13 +744,7 @@ impl IdentityWallet { retry shortly" .to_string(), ) - })?; - assemble_asset_lock_proof( - fetched.transaction, - fetched.is_chain_locked, - fetched.height, - invitation, - ) + }) } } @@ -762,6 +842,92 @@ where } } +/// Refuse a link whose voucher key is for the other network before any +/// network work: a testnet WIF is a valid key on the wrong chain, so it would +/// otherwise surface as a confusing funding-tx fetch miss. +fn ensure_invitation_network( + invitation: &ParsedInvitation, + wallet_network: Network, +) -> Result<(), PlatformWalletError> { + if wif_network_matches(invitation.voucher_key_network, wallet_network) { + Ok(()) + } else { + Err(PlatformWalletError::InvitationNetworkMismatch { + invitation: invitation.voucher_key_network, + wallet: wallet_network, + }) + } +} + +/// The identity id Platform derives from an asset-lock outpoint. Both proof +/// kinds hash only the outpoint, so the ChainLock derivation (which ignores +/// its height) gives the id for either. +fn identity_id_for_out_point(out_point: OutPoint) -> Identifier { + ChainAssetLockProof::new(0, out_point.into()).create_identifier() +} + +/// Recognize a claim Platform already executed. A claim repeated after an +/// earlier attempt landed (reported as a failure, or interrupted) is refused +/// because its outpoint is consumed; the identity at the prospective id is +/// ours when it carries exactly the keys we submitted, and is then the +/// claim's result. Anything else (no identity, someone else's, a failed +/// lookup) reports the original rejection. +/// +/// `fetch` looks up the identity at the prospective id: the injectable seam +/// (production passes `Identity::fetch`). +async fn adopt_already_landed_claim( + submitted: Result, + out_point: &OutPoint, + submitted_keys: &BTreeMap, + fetch: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future, dash_sdk::Error>>, +{ + let error = match submitted { + Err(PlatformWalletError::Sdk(error)) + if is_asset_lock_already_consumed(&error, out_point) => + { + error + } + other => return other, + }; + match fetch().await { + Ok(Some(identity)) if identity_carries_keys(&identity, submitted_keys) => { + tracing::info!( + identity_id = %identity.id(), + "invitation claim was already accepted by Platform; adopting its identity" + ); + Ok(identity) + } + Ok(_) => Err(PlatformWalletError::Sdk(error)), + Err(lookup) => { + tracing::warn!( + error = %lookup, + "could not look up the identity of a consumed invitation; reporting the rejection" + ); + Err(PlatformWalletError::Sdk(error)) + } + } +} + +/// The identity carries exactly `expected` public keys — same ids, same key +/// data. Key data is what only our signer could have produced, so a match +/// means the identity was created by our claim, not by a finder of the link. +fn identity_carries_keys( + identity: &Identity, + expected: &BTreeMap, +) -> bool { + let actual = identity.public_keys(); + actual.len() == expected.len() + && expected.iter().all(|(id, key)| { + actual.get(id).is_some_and(|found| { + found.data() == key.data() && found.key_type() == key.key_type() + }) + }) +} + /// The claim's reconstructed funding proof, plus an optional ChainLock fallback. /// /// `primary` is submitted first: an [`AssetLockProof::Instant`] when the link @@ -780,23 +946,30 @@ struct ReconstructedProof { chain_fallback: Option, } -/// Assemble the asset-lock proof from an already-fetched funding transaction — the -/// pure, testable core of the claim reconstruction (the fetch/retry lives in -/// `reconstruct_asset_lock_proof`). Validates the tx is the funding tx (either byte -/// order), selects the voucher's credit output, and builds an InstantSend proof -/// (link carried an islock) or a ChainLock proof (islock absent), requiring -/// chain-lock finality for the latter. -/// -/// When an islock is present AND the funding tx is already chain-locked, the -/// returned [`ReconstructedProof`] also carries a `chain_fallback` ChainLock proof -/// over the same credit output, so the caller can recover from a stale islock that -/// Platform rejects without refetching the tx. -fn assemble_asset_lock_proof( - transaction: Transaction, +/// What an invitation's funding transaction says before any proof is built. +#[derive(Debug)] +struct InvitationFunding { + /// The voucher's credit outpoint; the claim's identity id derives from it. + out_point: OutPoint, + /// Value of that credit output (duffs): what the invitation is worth. + credit_output_duffs: u64, + /// The link's islock, decoded and checked to lock this transaction. + instant_lock: Option, + /// The funding transaction is chain-locked. + is_chain_locked: bool, +} + +/// Check a fetched funding transaction against the link and select the +/// voucher's credit output: the pure core shared by the claim's proof +/// assembly and the read-only queries. Validates the tx is the funding tx +/// (either byte order), selects the voucher's credit output, and decodes the +/// link's islock (checking it locks this tx). Chain-lock finality is reported, +/// not required: only building a ChainLock proof needs it. +fn inspect_funding_tx( + transaction: &Transaction, is_chain_locked: bool, - height: u32, invitation: &ParsedInvitation, -) -> Result { +) -> Result { // Fail-fast: the fetched tx must actually be the funding tx (either byte // order). DAPI returns whatever tx matches the id we asked for, so this // guards a backend that answers with an unrelated tx. @@ -812,19 +985,10 @@ fn assemble_asset_lock_proof( // Select the funded credit output the voucher key controls (not index 0 // — a legacy invite's credit output need not be first). - let output_index = voucher_output_index(&transaction, &invitation.voucher_key)?; - - // A ChainLock proof over the selected credit output. Buildable only once the - // funding block is chain-locked; `height` is the tx's mined height (the - // `ChainAssetLockProof`'s `core_chain_locked_height`). Reused both as the - // primary for an islock-less invite and as the stale-islock fallback. - let chain_lock_proof = |txid| -> AssetLockProof { - let out_point = OutPoint::new(txid, output_index); - let out_point_bytes: [u8; 36] = out_point.into(); - AssetLockProof::Chain(ChainAssetLockProof::new(height, out_point_bytes)) - }; + let (output_index, credit_output) = + voucher_credit_output(transaction, &invitation.voucher_key)?; - match &invitation.islock_hex { + let instant_lock = match &invitation.islock_hex { Some(islock_hex) => { let islock_bytes = hex::decode(islock_hex).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( @@ -845,17 +1009,60 @@ fn assemble_asset_lock_proof( "invitation islock does not lock the funding transaction".to_string(), )); } + Some(instant_lock) + } + None => None, + }; + + Ok(InvitationFunding { + out_point: OutPoint::new(transaction.txid(), output_index), + credit_output_duffs: credit_output.value, + instant_lock, + is_chain_locked, + }) +} + +/// Assemble the asset-lock proof from an already-fetched funding transaction — the +/// pure, testable core of the claim reconstruction (the fetch/retry lives in +/// `reconstruct_asset_lock_proof`). Checks the tx with [`inspect_funding_tx`], then +/// builds an InstantSend proof (link carried an islock) or a ChainLock proof +/// (islock absent), requiring chain-lock finality for the latter. +/// +/// When an islock is present AND the funding tx is already chain-locked, the +/// returned [`ReconstructedProof`] also carries a `chain_fallback` ChainLock proof +/// over the same credit output, so the caller can recover from a stale islock that +/// Platform rejects without refetching the tx. +fn assemble_asset_lock_proof( + transaction: Transaction, + is_chain_locked: bool, + height: u32, + invitation: &ParsedInvitation, +) -> Result { + let InvitationFunding { + out_point, + instant_lock, + .. + } = inspect_funding_tx(&transaction, is_chain_locked, invitation)?; + + // A ChainLock proof over the selected credit output. Buildable only once the + // funding block is chain-locked; `height` is the tx's mined height (the + // `ChainAssetLockProof`'s `core_chain_locked_height`). Reused both as the + // primary for an islock-less invite and as the stale-islock fallback. + let chain_lock_proof = + || AssetLockProof::Chain(ChainAssetLockProof::new(height, out_point.into())); + + match instant_lock { + Some(instant_lock) => { // Fast path: submit the InstantSend proof. If the islock is stale // (quorum rotated / no longer "recent") Platform rejects it, and the // claim falls back to `chain_fallback` — available only when the // funding tx is already chain-locked (the usual case by claim time, - // since the voucher was funded minutes-to-hours earlier). Computed - // from `&transaction` BEFORE it is moved into the IS proof below. - let chain_fallback = is_chain_locked.then(|| chain_lock_proof(transaction.txid())); + // since the voucher was funded minutes-to-hours earlier). + let chain_fallback = is_chain_locked.then(chain_lock_proof); let primary = AssetLockProof::Instant(InstantAssetLockProof::new( instant_lock, transaction, - output_index, + out_point.vout, )); Ok(ReconstructedProof { primary, @@ -876,7 +1083,7 @@ fn assemble_asset_lock_proof( )); } Ok(ReconstructedProof { - primary: chain_lock_proof(transaction.txid()), + primary: chain_lock_proof(), chain_fallback: None, }) } @@ -1080,7 +1287,7 @@ mod tests { script_pubkey: voucher_credit_script(&decoy), }, TxOut { - value: 100_000, + value: 25_000_000, script_pubkey: voucher_credit_script(&key), }, ], @@ -1095,8 +1302,18 @@ mod tests { let txid = tx.txid(); let inv = parsed(key, txid.to_string(), None); + let funding = inspect_funding_tx(&tx, true, &inv).unwrap(); + assert_eq!( + funding.credit_output_duffs, 25_000_000, + "the amount must come from the voucher's output, not the decoy at index 0" + ); let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); let id = proof.primary.create_identifier().unwrap(); + assert_eq!( + identity_id_for_out_point(funding.out_point), + id, + "the read-only queries must derive the id the claim creates" + ); let from_index_0 = ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); @@ -1158,6 +1375,251 @@ mod tests { ); } + /// A test identity key: `byte` fills the key data, so two keys differ + /// exactly when their bytes do. + fn test_key(id: KeyID, byte: u8) -> IdentityPublicKey { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::KeyType; + use dpp::platform_value::BinaryData; + + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }) + } + + fn identity_with_keys(keys: BTreeMap) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys, + balance: 0, + revision: 0, + }) + } + + fn our_keys() -> BTreeMap { + [(0, test_key(0, 1)), (1, test_key(1, 2))].into() + } + + /// A claim that already landed is recognized as ours only by its keys: + /// the same key set is ours, anything else (a finder of the link claiming + /// first) is not. + #[test] + fn should_recognize_a_claimed_identity_only_by_our_keys() { + let ours = our_keys(); + assert!(identity_carries_keys( + &identity_with_keys(ours.clone()), + &ours + )); + let theirs: BTreeMap = + [(0, test_key(0, 9)), (1, test_key(1, 2))].into(); + assert!(!identity_carries_keys(&identity_with_keys(theirs), &ours)); + let fewer: BTreeMap = [(0, test_key(0, 1))].into(); + assert!(!identity_carries_keys(&identity_with_keys(fewer), &ours)); + } + + /// A testnet link in a mainnet wallet is refused with the typed mismatch + /// the FFI maps to `ErrorInvalidNetwork`. + #[test] + fn should_refuse_an_invitation_for_the_other_network() { + let inv = parsed(voucher_secret(), "00".repeat(32), None); + assert!(ensure_invitation_network(&inv, Network::Testnet).is_ok()); + assert!(matches!( + ensure_invitation_network(&inv, Network::Mainnet), + Err(PlatformWalletError::InvitationNetworkMismatch { + invitation: Network::Testnet, + wallet: Network::Mainnet, + }) + )); + } + + /// A ChainLock-only link whose funding tx is not chain-locked yet cannot + /// be claimed, but the read-only view still reads its amount and outpoint + /// and reports the missing ChainLock instead of failing. + #[test] + fn should_inspect_a_chainlock_invitation_before_it_is_chain_locked() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid(); + let inv = parsed(key, txid.to_string(), None); + + let funding = inspect_funding_tx(&tx, false, &inv).unwrap(); + assert_eq!(funding.credit_output_duffs, 100_000); + assert_eq!(funding.out_point, OutPoint::new(txid, 0)); + assert!(funding.instant_lock.is_none()); + assert!(!funding.is_chain_locked); + assert!( + assemble_asset_lock_proof(tx, false, 100, &inv).is_err(), + "the claim itself still waits for the ChainLock" + ); + } + + /// The InstantSend read-only view carries the decoded islock, and its id + /// matches the claim's InstantSend primary. + #[test] + fn should_inspect_an_instant_invitation_with_the_claims_identity_id() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let mut islock = InstantLock::default(); + islock.txid = tx.txid(); + let mut islock_bytes = Vec::new(); + islock.consensus_encode(&mut islock_bytes).unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + + let funding = inspect_funding_tx(&tx, true, &inv).unwrap(); + assert!(funding.instant_lock.is_some()); + assert_eq!(funding.credit_output_duffs, 100_000); + let primary = assemble_asset_lock_proof(tx, true, 100, &inv) + .unwrap() + .primary; + assert_eq!( + identity_id_for_out_point(funding.out_point), + primary.create_identifier().unwrap() + ); + } + + // --- adopt_already_landed_claim: recognizing our own earlier claim --- + + mod claim_adoption { + use std::cell::Cell; + + use super::super::adopt_already_landed_claim; + use super::{identity_with_keys, our_keys, test_key}; + use crate::PlatformWalletError; + use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{OutPoint, Txid}; + use dpp::identity::{Identity, IdentityPublicKey, KeyID}; + use std::collections::BTreeMap; + + fn out_point() -> OutPoint { + OutPoint::new(Txid::from_byte_array([7u8; 32]), 1) + } + + /// Platform's consumed-outpoint rejection for `out_point`. + fn consumed(out_point: OutPoint) -> PlatformWalletError { + PlatformWalletError::Sdk(dash_sdk::Error::Protocol( + dpp::ProtocolError::ConsensusError(Box::new(ConsensusError::BasicError( + BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError( + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + out_point.txid, + out_point.vout as usize, + ), + ), + ))), + )) + } + + fn is_consumed(result: &Result) -> bool { + matches!( + result, + Err(PlatformWalletError::Sdk(dash_sdk::Error::Protocol(_))) + ) + } + + /// Run the seam with a scripted lookup, counting how often it is called. + async fn adopt( + submitted: Result, + lookup: Result, dash_sdk::Error>, + ) -> (Result, u32) { + let calls = Cell::new(0); + let keys = our_keys(); + let result = adopt_already_landed_claim(submitted, &out_point(), &keys, || { + calls.set(calls.get() + 1); + async { lookup } + }) + .await; + (result, calls.get()) + } + + #[tokio::test] + async fn should_pass_a_successful_claim_through_without_a_lookup() { + let (result, lookups) = adopt(Ok(identity_with_keys(our_keys())), Ok(None)).await; + assert!(result.is_ok()); + assert_eq!(lookups, 0); + } + + #[tokio::test] + async fn should_adopt_our_identity_behind_a_consumed_outpoint() { + let (result, lookups) = adopt( + Err(consumed(out_point())), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(result.is_ok(), "our own landed claim is the claim's result"); + assert_eq!(lookups, 1); + } + + #[tokio::test] + async fn should_report_the_rejection_when_someone_else_claimed_first() { + let theirs: BTreeMap = [(0, test_key(0, 9))].into(); + let (result, _) = adopt( + Err(consumed(out_point())), + Ok(Some(identity_with_keys(theirs))), + ) + .await; + assert!(is_consumed(&result)); + } + + /// A reclaim top-up consumes the outpoint without creating the + /// identity: nothing to adopt. + #[tokio::test] + async fn should_report_the_rejection_when_no_identity_exists() { + let (result, _) = adopt(Err(consumed(out_point())), Ok(None)).await; + assert!(is_consumed(&result)); + } + + /// A failed lookup must not replace the rejection that explains the + /// failure. + #[tokio::test] + async fn should_keep_the_rejection_when_the_lookup_fails() { + let (result, _) = adopt( + Err(consumed(out_point())), + Err(dash_sdk::Error::Generic("timeout".to_string())), + ) + .await; + assert!(is_consumed(&result)); + } + + /// A consumed report for another outpoint is not about this claim. + #[tokio::test] + async fn should_ignore_a_consumed_report_for_another_outpoint() { + let other = OutPoint::new(Txid::from_byte_array([8u8; 32]), 1); + let (result, lookups) = adopt( + Err(consumed(other)), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(is_consumed(&result)); + assert_eq!(lookups, 0); + } + + #[tokio::test] + async fn should_pass_unrelated_errors_through_without_a_lookup() { + let (result, lookups) = adopt( + Err(PlatformWalletError::Sdk(dash_sdk::Error::Generic( + "unrelated".to_string(), + ))), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(matches!( + result, + Err(PlatformWalletError::Sdk(dash_sdk::Error::Generic(_))) + )); + assert_eq!(lookups, 0); + } + } + /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the /// txid-binding guard), so a link can't pair a valid islock with a foreign tx. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs index 4cec27dc3fa..a1cde7283a6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs @@ -5,13 +5,16 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; +use super::signing_key::first_available; use crate::util::now_ms; /// The first AUTHENTICATION key of `identity` at one of `security_levels`, of one of -/// `key_types`, that can sign a `document_type_name` document of `contract_id` now. +/// `key_types`, that can sign a `document_type_name` document of `contract_id` now +/// AND that `signer` reports it can sign with. /// /// A key bound to another contract, or to another document type of this contract, cannot /// authorize the write and is skipped. A key without limits is preferred: a key with a budget @@ -19,13 +22,17 @@ use crate::util::now_ms; /// key is skipped, and so is a key whose expiry has passed the wall clock (the block time /// trails it by seconds at most). What is left of a budget is not known offline; a spent key /// is refused by Platform. +/// +/// `Ok(None)` means no key is eligible at all; `Err` means at least one eligible key exists +/// but the signer cannot reach any of them. pub(crate) fn usable_authentication_key<'a>( identity: &'a Identity, + signer: &impl Signer, contract_id: Identifier, document_type_name: &str, security_levels: &[SecurityLevel], key_types: &[KeyType], -) -> Option<&'a IdentityPublicKey> { +) -> Result, dash_sdk::Error> { let now = now_ms(); let qualifies = |key: &IdentityPublicKey| { key.purpose() == Purpose::AUTHENTICATION @@ -36,9 +43,13 @@ pub(crate) fn usable_authentication_key<'a>( && !key.is_expired_at(now) }; let keys = identity.public_keys(); - keys.values() - .find(|key| qualifies(key) && !key.has_limits()) - .or_else(|| keys.values().find(|key| qualifies(key))) + let unlimited = keys + .values() + .filter(|key| qualifies(key) && !key.has_limits()); + let limited = keys + .values() + .filter(|key| qualifies(key) && key.has_limits()); + first_available(unlimited.chain(limited), signer) } /// Whether a key carrying `bounds` may sign a `document_type_name` document of `contract_id`. @@ -64,7 +75,9 @@ fn bounds_cover( #[cfg(test)] mod tests { + use super::super::signing_key::tests::{available, KeyFilter}; use super::*; + use crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; use dpp::identity::KeyID; @@ -112,11 +125,13 @@ mod tests { fn pick(identity: &Identity) -> Option<&IdentityPublicKey> { usable_authentication_key( identity, + &KeyFilter(|_| true), Identifier::from(CONTRACT), DOCUMENT_TYPE, &LEVELS, &TYPES, ) + .unwrap() } const LEVELS: [SecurityLevel; 2] = [SecurityLevel::HIGH, SecurityLevel::CRITICAL]; @@ -233,4 +248,76 @@ mod tests { "group membership is state the wallet does not hold" ); } + + fn pick_with_signer( + identity: &Identity, + ids: &[KeyID], + ) -> Result, dash_sdk::Error> { + usable_authentication_key( + identity, + &available(ids), + Identifier::from(CONTRACT), + DOCUMENT_TYPE, + &LEVELS, + &TYPES, + ) + .map(|key| key.map(|k| k.id())) + } + + fn bound_elsewhere(id: KeyID) -> IdentityPublicKey { + bound_key( + id, + SecurityLevel::HIGH, + Some(ContractBounds::SingleContract { + id: Identifier::from(OTHER_CONTRACT), + }), + ) + } + + #[test] + fn falls_back_past_an_unavailable_unlimited_key_to_an_available_limited_one() { + let subject = identity(vec![ + key(1, SecurityLevel::CRITICAL), + bound_elsewhere(2), + key(3, SecurityLevel::HIGH).with_limits(Some(1_000), None), + ]); + assert_eq!( + pick_with_signer(&subject, &[2, 3]).unwrap(), + Some(3), + "unavailable key 1 is skipped, and key 2 is not usable here even though \ + the signer holds it" + ); + } + + #[test] + fn errs_naming_the_eligible_key_when_only_ineligible_keys_are_available() { + let subject = identity(vec![ + bound_elsewhere(1), + key(2, SecurityLevel::HIGH).with_limits(Some(1_000), None), + key(3, SecurityLevel::CRITICAL).with_limits(None, Some(1)), + ]); + let err = pick_with_signer(&subject, &[1, 3]).unwrap_err().to_string(); + assert!( + err.contains(SIGNER_KEY_UNAVAILABLE_PREFIX), + "must surface as signer-unavailable: {err}" + ); + assert!( + err.contains("Signing key 2 "), + "only key 2 is eligible, so it is the one reported: {err}" + ); + } + + #[test] + fn answers_none_rather_than_err_when_no_key_is_eligible_at_all() { + let subject = identity(vec![ + key(0, SecurityLevel::MASTER), + bound_elsewhere(1), + key(2, SecurityLevel::HIGH).with_limits(None, Some(1)), + ]); + assert_eq!( + pick_with_signer(&subject, &[]).unwrap(), + None, + "unavailable but ineligible keys must not turn `None` into a signer error" + ); + } } 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 4bd019c8392..92159f65d3d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -48,7 +48,8 @@ mod contacts; mod dashpay_view; mod invitation; pub use invitation::{ - Invitation, MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, + Invitation, InvitationClaimStatus, MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, + MIN_INVITATION_DUFFS, }; mod payment_handler; pub(crate) use payment_handler::DashPayPaymentHandler; @@ -61,6 +62,7 @@ pub(crate) use payments::{record_incoming_dashpay_payments, sent_payment_status_ mod profile; pub(crate) mod sdk_writer; mod seed_binding; +mod signing_key; pub use seed_binding::SeedBindingVerification; // Token state-transition operations (same `IdentityWallet` impl blocks). diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index dbd61fdd628..9e76f32d7c7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -21,13 +21,15 @@ use crate::wallet::identity::{ContactProfileEntry, DashPayProfile}; // Profile documents require HIGH or CRITICAL authentication; MASTER is reserved // for identity operations and cannot authorize an ordinary document write. A key // bound to another contract or document type is skipped, a key without limits is -// preferred and an expired one is skipped. -fn profile_signing_key( - identity: &Identity, +// preferred, and an expired one or one the signer cannot sign with is skipped. +fn profile_signing_key<'a>( + identity: &'a Identity, dashpay_contract_id: Identifier, -) -> Option<&IdentityPublicKey> { + signer: &impl Signer, +) -> Result, dash_sdk::Error> { super::usable_authentication_key( identity, + signer, dashpay_contract_id, "profile", &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], @@ -130,7 +132,7 @@ impl DashPayView<'_, B> { /// is resolved from the identity's active HIGH or CRITICAL ECDSA /// authentication keys (full public key or HASH160) — the signer /// is responsible for producing a signature for whatever key is - /// picked. + /// picked. Keys `signer.can_sign_with` rejects are skipped. /// /// All other behavior — avatar hashing, document construction, /// local cache update via the persister — is identical to the @@ -190,7 +192,9 @@ impl DashPayView<'_, B> { .identity_manager .managed_identity(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - profile_signing_key(&managed.identity, dashpay_contract.id()) + let identity = managed.identity.clone(); + drop(wm); + profile_signing_key(&identity, dashpay_contract.id(), signer)? .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( @@ -339,7 +343,9 @@ impl DashPayView<'_, B> { .identity_manager .managed_identity(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - profile_signing_key(&managed.identity, dashpay_contract.id()) + let identity = managed.identity.clone(); + drop(wm); + profile_signing_key(&identity, dashpay_contract.id(), signer)? .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( @@ -789,6 +795,7 @@ fn contact_profiles_chunk_query( #[cfg(test)] mod tests { + use super::super::signing_key::tests::KeyFilter; use super::*; use crate::wallet::identity::ProfileUpdate; use std::collections::BTreeMap; @@ -816,7 +823,7 @@ mod tests { const DASHPAY: [u8; 32] = [0xDA; 32]; fn pick(identity: &Identity) -> Option<&IdentityPublicKey> { - profile_signing_key(identity, Identifier::from(DASHPAY)) + profile_signing_key(identity, Identifier::from(DASHPAY), &KeyFilter(|_| true)).unwrap() } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs new file mode 100644 index 00000000000..5e09f7af239 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -0,0 +1,425 @@ +//! Signer-aware identity key selection shared by wallet operations. + +use crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::ProtocolError; + +/// Preserve the signer's unavailable-key discriminator through SDK and FFI errors. +fn signing_key_unavailable(key: &IdentityPublicKey) -> dash_sdk::Error { + ProtocolError::Generic(format!( + "{SIGNER_KEY_UNAVAILABLE_PREFIX}Signing key {} is unavailable to signer", + key.id() + )) + .into() +} + +/// `key` itself, or the signer-unavailable error when `signer` cannot sign with it. +pub(super) fn require_available<'a>( + key: &'a IdentityPublicKey, + signer: &impl Signer, +) -> Result<&'a IdentityPublicKey, dash_sdk::Error> { + if signer.can_sign_with(key) { + Ok(key) + } else { + Err(signing_key_unavailable(key)) + } +} + +/// The first of the eligible `keys` that `signer` can sign with. +/// `Ok(None)` means no eligible key; `Err` means eligible keys are unavailable. +pub(super) fn first_available<'a>( + keys: impl Iterator, + signer: &impl Signer, +) -> Result, dash_sdk::Error> { + let mut unavailable = None; + for key in keys { + if signer.can_sign_with(key) { + return Ok(Some(key)); + } + unavailable.get_or_insert(key); + } + unavailable.map_or(Ok(None), |key| Err(signing_key_unavailable(key))) +} + +/// Signer-aware `get_first_public_key_matching`: key-ID order, same eligibility policy. +/// Call on an identity snapshot, outside wallet manager guards. +/// `Ok(None)` means no eligible key; `Err` means eligible keys are unavailable. +pub(super) trait AvailableSigningKey { + fn available_signing_key( + &self, + signer: &impl Signer, + purpose: Purpose, + security_levels: &[SecurityLevel], + key_types: &[KeyType], + allow_disabled: bool, + ) -> Result, dash_sdk::Error>; +} + +impl AvailableSigningKey for Identity { + fn available_signing_key( + &self, + signer: &impl Signer, + purpose: Purpose, + security_levels: &[SecurityLevel], + key_types: &[KeyType], + allow_disabled: bool, + ) -> Result, dash_sdk::Error> { + let eligible = self.public_keys().values().filter(|key| { + key.purpose() == purpose + && security_levels.contains(&key.security_level()) + && key_types.contains(&key.key_type()) + && (allow_disabled || !key.is_disabled()) + }); + first_available(eligible, signer) + } +} + +/// Respect explicit keys; automatic withdrawals exhaust TRANSFER before OWNER. +pub(super) fn credit_signing_key<'a>( + identity: &'a Identity, + explicit: Option<&'a IdentityPublicKey>, + signer: &impl Signer, + allow_owner: bool, +) -> Result<&'a IdentityPublicKey, dash_sdk::Error> { + if let Some(key) = explicit { + return require_available(key, signer); + } + let mut unavailable = None; + for purpose in [Purpose::TRANSFER] + .into_iter() + .chain(allow_owner.then_some(Purpose::OWNER)) + { + match identity.available_signing_key( + signer, + purpose, + &SecurityLevel::full_range(), + &KeyType::all_key_types(), + true, + ) { + Ok(Some(key)) => return Ok(key), + Ok(None) => {} + Err(error) => { + unavailable.get_or_insert(error); + } + } + } + Err(unavailable.unwrap_or_else(|| { + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "No requested credit signing key available to signer".to_string(), + ) + .into() + })) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use async_trait::async_trait; + use dpp::address_funds::AddressWitness; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + + /// Test signer that can sign with the keys `F` accepts; selection must never sign. + pub(crate) struct KeyFilter bool>(pub F); + + impl bool> std::fmt::Debug for KeyFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("KeyFilter") + } + } + + #[async_trait] + impl bool + Send + Sync> Signer for KeyFilter { + async fn sign(&self, _: &IdentityPublicKey, _: &[u8]) -> Result { + panic!("key selection must not sign") + } + async fn sign_create_witness( + &self, + _: &IdentityPublicKey, + _: &[u8], + ) -> Result { + panic!("key selection must not create a witness") + } + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + (self.0)(key) + } + } + + /// A signer available only for the listed key ids. + pub(crate) fn available( + ids: &[u32], + ) -> KeyFilter bool + Send + Sync + '_> { + KeyFilter(move |key| ids.contains(&key.id())) + } + + /// A signer with no available keys that asserts the wallet manager lock is free. + pub(crate) fn lock_checking_signer( + wallet: &super::super::IdentityWallet, + ) -> KeyFilter bool + Send + Sync + '_> { + KeyFilter(move |_| { + assert!( + wallet.wallet_manager.try_write().is_ok(), + "signer callback holds wallet manager lock" + ); + false + }) + } + + pub(crate) fn identity(purpose: Purpose, level: SecurityLevel, key_type: KeyType) -> Identity { + let mut identity = Identity::default_versioned(PlatformVersion::latest()).unwrap(); + for id in [1, 2] { + identity.add_public_key( + IdentityPublicKeyV0 { + id, + purpose, + security_level: level, + key_type, + data: vec![id as u8; key_type.default_size()].into(), + ..Default::default() + } + .into(), + ); + } + identity + } + + fn assert_unavailable(error: dash_sdk::Error) { + assert!( + matches!(error, dash_sdk::Error::Protocol(ProtocolError::Generic(ref message)) + if message.starts_with(SIGNER_KEY_UNAVAILABLE_PREFIX)), + "{error:?}" + ); + } + + fn assert_missing(result: Result<&IdentityPublicKey, dash_sdk::Error>) { + assert!( + matches!( + result, + Err(dash_sdk::Error::Protocol( + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing(_) + )) + ), + "{result:?}" + ); + } + + #[test] + fn should_skip_unavailable_keys_and_distinguish_them_from_ineligible_keys() { + let mut identity = identity( + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_HASH160, + ); + let mut ineligible = identity.public_keys().get(&2).unwrap().clone(); + ineligible.set_id(3); + ineligible.set_purpose(Purpose::ENCRYPTION); + identity.add_public_key(ineligible); + let select = |identity: &Identity, ids: &[u32]| { + identity + .available_signing_key( + &available(ids), + Purpose::AUTHENTICATION, + &[SecurityLevel::HIGH], + &[KeyType::ECDSA_HASH160], + false, + ) + .map(|key| key.map(|key| key.id())) + }; + assert_eq!(select(&identity, &[2, 3]).unwrap(), Some(2)); + assert_eq!(select(&identity, &[1, 2]).unwrap(), Some(1)); + assert_unavailable(select(&identity, &[3]).unwrap_err()); + identity.public_keys_mut().retain(|id, _| *id == 3); + assert_eq!(select(&identity, &[3]).unwrap(), None); + } + + #[test] + fn should_not_relax_eligibility_for_available_keys() { + use KeyType::{ECDSA_HASH160 as HASH160, ECDSA_SECP256K1 as SECP}; + use Purpose::{AUTHENTICATION as AUTH, TRANSFER}; + use SecurityLevel::{CRITICAL, HIGH}; + let mut identity = identity(AUTH, CRITICAL, SECP); + let key = identity.public_keys_mut().get_mut(&1).unwrap(); + key.set_disabled_at(1); + let select = |purpose, level, key_type| { + let signer = available(&[1, 2]); + let key = + identity.available_signing_key(&signer, purpose, &[level], &[key_type], false); + key.unwrap().map(|k| k.id()) + }; + assert_eq!( + select(AUTH, CRITICAL, SECP), + Some(2), + "disabled key 1 is skipped" + ); + for (purpose, level, key_type) in [ + (TRANSFER, CRITICAL, SECP), + (AUTH, HIGH, SECP), + (AUTH, CRITICAL, HASH160), + ] { + assert_eq!(select(purpose, level, key_type), None); + } + } + + #[test] + fn should_exhaust_transfer_keys_including_disabled_before_owner_fallback() { + let mut identity = identity( + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_HASH160, + ); + let mut owner = identity.public_keys().get(&1).unwrap().clone(); + owner.set_id(0); + owner.set_purpose(Purpose::OWNER); + identity.add_public_key(owner); + identity + .public_keys_mut() + .get_mut(&1) + .unwrap() + .set_disabled_at(1); + let select = |ids: &[u32], allow_owner| { + credit_signing_key(&identity, None, &available(ids), allow_owner).map(|k| k.id()) + }; + assert_eq!(select(&[0, 1], true).unwrap(), 1); + assert_eq!(select(&[0, 2], true).unwrap(), 2); + assert_eq!(select(&[0], true).unwrap(), 0); + assert_unavailable(select(&[0], false).unwrap_err()); + } + + #[test] + fn should_distinguish_unavailable_credit_keys_from_missing_purposes() { + let mut identity = identity( + Purpose::OWNER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_HASH160, + ); + assert_missing(credit_signing_key( + &identity, + None, + &available(&[1, 2]), + false, + )); + assert_unavailable(credit_signing_key(&identity, None, &available(&[]), true).unwrap_err()); + identity.public_keys_mut().clear(); + for allow_owner in [false, true] { + assert_missing(credit_signing_key( + &identity, + None, + &available(&[]), + allow_owner, + )); + } + } + + #[test] + fn should_not_substitute_an_explicit_credit_key() { + let identity = identity( + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_HASH160, + ); + let first = identity.public_keys().get(&1).unwrap(); + let second = identity.public_keys().get(&2).unwrap(); + assert_unavailable( + credit_signing_key(&identity, Some(first), &available(&[2]), true).unwrap_err(), + ); + assert_eq!( + credit_signing_key(&identity, Some(second), &available(&[1, 2]), false).unwrap(), + second + ); + } + + #[tokio::test] + async fn should_surface_unavailable_signer_from_credit_operations() { + let wallet = wallet_with_signing_keys().await; + let identity = identity( + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_HASH160, + ); + for explicit in [None, identity.public_keys().get(&1)] { + let transfer = wallet.identity().transfer_credits_with_signer( + &identity, + Identifier::from([2; 32]), + 1, + explicit, + available(&[]), + None, + ); + assert_unavailable(transfer.await.unwrap_err()); + let withdraw = wallet.identity().withdraw_credits_with_signer( + &identity, + None, + 1, + explicit, + available(&[]), + None, + ); + assert_unavailable(withdraw.await.unwrap_err()); + } + } + + pub(crate) async fn wallet_with_signing_keys() -> std::sync::Arc { + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::persister::NoPlatformPersistence; + use crate::PlatformWalletManager; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use std::sync::Arc; + + struct Events; + impl EventHandler for Events {} + impl PlatformEventHandler for Events {} + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(NoPlatformPersistence), + Arc::new(Events), + ); + let wallet = manager + .create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &[7; 64], + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .unwrap(); + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + identity( + Purpose::AUTHENTICATION, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + ), + 0, + wallet.wallet_id(), + &wallet.identity().persister, + ) + .unwrap(); + wallet + } + + #[tokio::test] + async fn should_release_profile_wallet_lock_before_signer_callback() { + let wallet = wallet_with_signing_keys().await; + let signer = lock_checking_signer(wallet.identity()); + let error = wallet + .identity() + .dashpay() + .create_profile_with_external_signer( + &dpp::prelude::Identifier::default(), + crate::wallet::identity::ProfileUpdate::default(), + &signer, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("available to signer"), "{error}"); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs index ad92058d0a3..f8640ad1631 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/burn.rs @@ -63,7 +63,7 @@ impl IdentityWallet { signer: &S, ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_burn_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs index e4fac83faf6..ee1edcff33d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/claim.rs @@ -60,7 +60,7 @@ impl IdentityWallet { signer: &S, ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_claim_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs index 67dca9b15d1..57e6b2b28f5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/destroy_frozen_funds.rs @@ -73,7 +73,7 @@ impl IdentityWallet { PlatformWalletError, > { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_destroy_frozen_funds_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs index 51861bfb9c4..64a76f47370 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/freeze.rs @@ -66,7 +66,7 @@ impl IdentityWallet { signer: &S, ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_freeze_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs index 7b2885325ee..300a32c62d3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs @@ -12,10 +12,11 @@ //! all rejected by Drive. See //! `state_transitions/document/batch_transition/methods/v0/mod.rs:133-138`. +use super::super::signing_key::AvailableSigningKey; use std::sync::Arc; use dpp::data_contract::DataContract; -use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::signer::Signer; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; @@ -37,6 +38,7 @@ impl IdentityWallet { pub(super) async fn token_resolve_signing_key( &self, identity_id: &Identifier, + signer: &impl Signer, ) -> Result { let wm = self.wallet_manager.read().await; let info = wm @@ -48,14 +50,16 @@ impl IdentityWallet { .identity(identity_id) .map(|m| m.identity.clone()) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + drop(wm); let signing_key = identity - .get_first_public_key_matching( + .available_signing_key( + signer, Purpose::AUTHENTICATION, - [SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), + &[SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], false, - ) + )? .ok_or_else(|| { PlatformWalletError::InvalidIdentityData(format!( "No AUTHENTICATION ECDSA_SECP256K1 key at CRITICAL security level on identity {} — \ @@ -101,3 +105,23 @@ impl IdentityWallet { Ok(contract) } } + +#[cfg(test)] +mod signing_tests { + use super::*; + use crate::wallet::identity::network::signing_key::tests::{ + lock_checking_signer, wallet_with_signing_keys, + }; + + #[tokio::test] + async fn should_check_token_key_availability_outside_wallet_lock() { + let wallet = wallet_with_signing_keys().await; + let signer = lock_checking_signer(wallet.identity()); + let error = wallet + .identity() + .token_resolve_signing_key(&Identifier::default(), &signer) + .await + .unwrap_err(); + assert!(error.to_string().contains("available to signer"), "{error}"); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs index a7fec47add6..483f613503a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs @@ -118,7 +118,7 @@ impl IdentityWallet { signer: &S, ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_mint_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs index 0a2bc4ab48b..488d381db95 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/pause.rs @@ -66,7 +66,7 @@ impl IdentityWallet { ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_pause_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs index 5e0f11a1f22..a97e4858574 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/purchase.rs @@ -65,7 +65,7 @@ impl IdentityWallet { ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_purchase_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs index d7ac7d90bb2..a55734a1a17 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/resume.rs @@ -65,7 +65,7 @@ impl IdentityWallet { ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_resume_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs index 842a8f89f81..a23cdd874d0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/set_price.rs @@ -83,7 +83,7 @@ impl IdentityWallet { use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; let pricing_schedule = if price_per_token == 0 { None diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs index c1ad8e58a4a..baa692ed13b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/transfer.rs @@ -68,7 +68,9 @@ impl IdentityWallet { signer: &S, ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&from_identity_id).await?; + let signing_key = self + .token_resolve_signing_key(&from_identity_id, signer) + .await?; self.token_transfer_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs index f43ac1ab532..fb749d4809f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/unfreeze.rs @@ -68,7 +68,7 @@ impl IdentityWallet { signer: &S, ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_unfreeze_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs index eadb9b0cb72..047b853cb2c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/update_config.rs @@ -72,7 +72,7 @@ impl IdentityWallet { ) -> Result { let data_contract = self.token_fetch_data_contract(token_contract_id).await?; - let signing_key = self.token_resolve_signing_key(&identity_id).await?; + let signing_key = self.token_resolve_signing_key(&identity_id, signer).await?; self.token_update_config_with_signer( data_contract, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs index 899d0532b61..3fefc2e7fb4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs @@ -18,6 +18,7 @@ use dash_sdk::platform::transition::transfer::{ use crate::error::PlatformWalletError; use crate::BlockTime; +use super::signing_key::credit_signing_key; use super::*; // Local borrowed-signer adapter — mirrors the one in `dpns.rs`. Lets @@ -103,7 +104,7 @@ impl IdentityWallet { &self.sdk, *to_id, amount, - None, // signing_transfer_key_to_use + Some(credit_signing_key(&identity, None, signer, false)?), SignerRef(signer), settings, ) @@ -157,12 +158,14 @@ impl IdentityWallet { signer: S, settings: Option, ) -> Result<(u64, u64), dash_sdk::Error> { + let signing_key = + credit_signing_key(identity, signing_transfer_key_to_use, &signer, false)?; identity .transfer_credits( &self.sdk, to_id, amount, - signing_transfer_key_to_use, + Some(signing_key), signer, settings, ) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs index 12fd3eee08d..66d0862cd45 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs @@ -19,6 +19,7 @@ use dpp::fee::Credits; use crate::error::PlatformWalletError; use crate::BlockTime; +use super::signing_key::credit_signing_key; use super::*; // Borrowed-signer adapter — see `dpns.rs` for the pattern. @@ -104,7 +105,7 @@ impl IdentityWallet { .transfer_credits_to_addresses_with_metadata( &self.sdk, recipient_addresses, - None, // signing_transfer_key_to_use + Some(credit_signing_key(&identity, None, signer, false)?), &SignerRef(signer), settings, ) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 475a12eb2e3..f6e6c416907 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -21,6 +21,7 @@ use dash_sdk::platform::transition::put_settings::PutSettings; use crate::error::PlatformWalletError; +use super::signing_key::AvailableSigningKey; use super::*; // Borrowed-signer adapter — see `dpns.rs` for the same pattern. @@ -136,14 +137,14 @@ impl IdentityWallet { // Pick the MASTER signing key — DPP requires identity update // transitions to be authorized by MASTER specifically. let master_key_id = identity - .public_keys() - .iter() - .find(|(_, key)| { - key.purpose() == Purpose::AUTHENTICATION - && key.security_level() == SecurityLevel::MASTER - && key.key_type() == KeyType::ECDSA_SECP256K1 - }) - .map(|(id, _)| *id) + .available_signing_key( + signer, + Purpose::AUTHENTICATION, + &[SecurityLevel::MASTER], + &[KeyType::ECDSA_SECP256K1], + true, + )? + .map(|key| key.id()) .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "No signable master key found on identity".to_string(), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index f791a4cdfb3..d7219ed37c1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -20,6 +20,7 @@ use dash_sdk::platform::transition::withdraw_from_identity::{ use crate::error::PlatformWalletError; use crate::BlockTime; +use super::signing_key::credit_signing_key; use super::*; // Borrowed-signer adapter — see `dpns.rs`/`transfer.rs` for the same @@ -101,7 +102,7 @@ impl IdentityWallet { Some(to_address.clone()), amount, None, // core_fee_per_byte - None, // signing_withdrawal_key_to_use + Some(credit_signing_key(&identity, None, signer, true)?), SignerRef(signer), settings, ) @@ -157,13 +158,15 @@ impl IdentityWallet { signer: S, settings: Option, ) -> Result { + let signing_key = + credit_signing_key(identity, signing_withdrawal_key_to_use, &signer, true)?; identity .withdraw( &self.sdk, to_address, amount, Some(1), // core_fee_per_byte - signing_withdrawal_key_to_use, + Some(signing_key), signer, settings, ) diff --git a/packages/rs-sdk/src/platform/transition/put_identity.rs b/packages/rs-sdk/src/platform/transition/put_identity.rs index e41648d0f82..0a27297e9d6 100644 --- a/packages/rs-sdk/src/platform/transition/put_identity.rs +++ b/packages/rs-sdk/src/platform/transition/put_identity.rs @@ -22,6 +22,7 @@ use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; use drive_proof_verifier::types::AddressInfos; use std::collections::{BTreeMap, BTreeSet}; +use tracing::debug; /// Trait for creating identities on the platform. #[async_trait::async_trait] @@ -45,6 +46,10 @@ pub trait PutIdentity>: Waitable { /// Creates an identity using an asset lock and waits for confirmation. /// + /// A broadcast answered with `AlreadyExists` (this exact transition is + /// already in the mempool or on chain) is not an error: the result wait + /// that follows resolves it. + /// /// In-process private-key counterpart to /// [`Self::put_to_platform_and_wait_for_response_with_signer`]. async fn put_to_platform_and_wait_for_response_with_private_key( @@ -82,7 +87,8 @@ pub trait PutIdentity>: Waitable { AS: dpp::key_wallet::signer::Signer + Send + Sync; /// Creates an identity using an asset-lock signer and waits for - /// confirmation. + /// confirmation. `AlreadyExists` from the broadcast is resolved by the + /// wait, as in the private-key variant. /// /// Signer-driven counterpart to /// [`Self::put_to_platform_and_wait_for_response_with_private_key`]. @@ -151,7 +157,7 @@ impl> PutIdentity for Identity { signer: &IS, settings: Option, ) -> Result { - put_identity_with_asset_lock_and_private_key( + let state_transition = identity_create_with_asset_lock_and_private_key( self, sdk, asset_lock_proof, @@ -159,7 +165,9 @@ impl> PutIdentity for Identity { signer, settings, ) - .await + .await?; + state_transition.broadcast(sdk, settings).await?; + Ok(state_transition) } async fn put_to_platform_and_wait_for_response_with_private_key( @@ -170,17 +178,20 @@ impl> PutIdentity for Identity { signer: &IS, settings: Option, ) -> Result { - let state_transition = self - .put_to_platform_with_private_key( - sdk, - asset_lock_proof, - asset_lock_proof_private_key, - signer, - settings, - ) - .await?; - - Self::wait_for_response(sdk, state_transition, settings).await + let state_transition = identity_create_with_asset_lock_and_private_key( + self, + sdk, + asset_lock_proof, + asset_lock_proof_private_key, + signer, + settings, + ) + .await?; + let broadcast = state_transition.broadcast(sdk, settings).await; + broadcast_then_wait(broadcast, state_transition, |st| { + Self::wait_for_response(sdk, st, settings) + }) + .await } #[cfg(feature = "core_key_wallet")] @@ -196,7 +207,7 @@ impl> PutIdentity for Identity { where AS: dpp::key_wallet::signer::Signer + Send + Sync, { - put_identity_with_asset_lock_and_signer( + let state_transition = identity_create_with_asset_lock_and_signer( self, sdk, asset_lock_proof, @@ -205,7 +216,9 @@ impl> PutIdentity for Identity { identity_signer, settings, ) - .await + .await?; + state_transition.broadcast(sdk, settings).await?; + Ok(state_transition) } #[cfg(feature = "core_key_wallet")] @@ -221,18 +234,21 @@ impl> PutIdentity for Identity { where AS: dpp::key_wallet::signer::Signer + Send + Sync, { - let state_transition = self - .put_to_platform_with_signer( - sdk, - asset_lock_proof, - asset_lock_proof_path, - asset_lock_signer, - identity_signer, - settings, - ) - .await?; - - Self::wait_for_response(sdk, state_transition, settings).await + let state_transition = identity_create_with_asset_lock_and_signer( + self, + sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + identity_signer, + settings, + ) + .await?; + let broadcast = state_transition.broadcast(sdk, settings).await; + broadcast_then_wait(broadcast, state_transition, |st| { + Self::wait_for_response(sdk, st, settings) + }) + .await } async fn put_with_address_funding + Send + Sync>( @@ -282,7 +298,42 @@ impl> PutIdentity for Identity { } } -async fn put_identity_with_asset_lock_and_private_key>( +/// A broadcast whose result is waited for next: `AlreadyExists` counts as +/// delivered. DAPI returns it only when this exact transition is already in +/// the mempool or on chain (a broadcast that timed out after the node took it, +/// retried on another node), so the wait resolves it; failing here would +/// report an identity create that lands as a failure. +fn already_known_is_delivered(broadcast: Result<(), Error>) -> Result<(), Error> { + match broadcast { + Err(Error::AlreadyExists(message)) => { + debug!(%message, "identity create is already known to Platform; waiting for its result"); + Ok(()) + } + other => other, + } +} + +/// After an identity create's broadcast, wait for its result — the step both +/// `put_to_platform_and_wait_for_response_*` variants share, with the wait +/// injected so it can be tested without a Platform: the same signed +/// transition is waited for after a delivered broadcast, including one DAPI +/// reports as already known, and nothing is waited for after any other +/// broadcast failure. +async fn broadcast_then_wait( + broadcast: Result<(), Error>, + state_transition: StateTransition, + wait: W, +) -> Result +where + W: FnOnce(StateTransition) -> WFut, + WFut: std::future::Future>, +{ + already_known_is_delivered(broadcast)?; + wait(state_transition).await +} + +/// Build and structurally validate the identity create, without broadcasting. +async fn identity_create_with_asset_lock_and_private_key>( identity: &Identity, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -307,13 +358,14 @@ async fn put_identity_with_asset_lock_and_private_key( +async fn identity_create_with_asset_lock_and_signer( identity: &Identity, sdk: &Sdk, asset_lock_proof: AssetLockProof, @@ -329,7 +381,7 @@ where // `broadcast_request_for_new_identity_with_signer` reads // `PutSettings::user_fee_increase` internally; thread `settings` // through to honour the CL-height retry's hash-bumping mechanism - // (see the matching block in `put_identity_with_asset_lock_and_private_key`). + // (see the matching block in `identity_create_with_asset_lock_and_private_key`). let (state_transition, _) = identity .broadcast_request_for_new_identity_with_signer( asset_lock_proof, @@ -341,7 +393,6 @@ where ) .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - state_transition.broadcast(sdk, settings).await?; Ok(state_transition) } @@ -417,3 +468,102 @@ async fn put_identity_with_address_funding< ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_treat_an_already_known_broadcast_as_delivered() { + let broadcast = Err(Error::AlreadyExists( + "state transition already in mempool".to_string(), + )); + assert!(already_known_is_delivered(broadcast).is_ok()); + } + + fn signed_create() -> StateTransition { + use dpp::state_transition::identity_create_transition::v0::IdentityCreateTransitionV0; + StateTransition::IdentityCreate( + IdentityCreateTransitionV0 { + signature: vec![7; 65].into(), + ..Default::default() + } + .into(), + ) + } + + fn created() -> Identity { + use dpp::identity::v0::IdentityV0; + Identity::V0(IdentityV0 { + id: dpp::prelude::Identifier::from([9; 32]), + public_keys: Default::default(), + balance: 1, + revision: 0, + }) + } + + #[tokio::test] + async fn should_wait_for_the_same_transition_after_an_already_known_broadcast() { + let submitted = signed_create(); + let waited_for = std::sync::Mutex::new(None); + let result = broadcast_then_wait( + Err(Error::AlreadyExists( + "tx already exists in cache".to_string(), + )), + submitted.clone(), + |st| { + *waited_for.lock().unwrap() = Some(st); + async { Ok(created()) } + }, + ) + .await; + assert_eq!( + result.unwrap(), + created(), + "the wait's identity is the result" + ); + assert_eq!( + waited_for.lock().unwrap().as_ref(), + Some(&submitted), + "the wait must be for the transition that was broadcast" + ); + } + + #[tokio::test] + async fn should_return_the_wait_rejection_after_an_already_known_broadcast() { + let result = broadcast_then_wait( + Err(Error::AlreadyExists( + "state transition already in mempool".to_string(), + )), + signed_create(), + |_| async { Err(Error::Generic("rejected by Platform".to_string())) }, + ) + .await; + assert!( + matches!(result, Err(Error::Generic(message)) if message == "rejected by Platform") + ); + } + + #[tokio::test] + async fn should_not_wait_after_any_other_broadcast_failure() { + let waited = std::sync::atomic::AtomicBool::new(false); + let result = broadcast_then_wait( + Err(Error::Generic("broadcast rejected".to_string())), + signed_create(), + |_| { + waited.store(true, std::sync::atomic::Ordering::SeqCst); + async { Ok(created()) } + }, + ) + .await; + assert!(matches!(result, Err(Error::Generic(_)))); + assert!(!waited.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn should_keep_every_other_broadcast_outcome() { + assert!(already_known_is_delivered(Ok(())).is_ok()); + let rejected = already_known_is_delivered(Err(Error::Generic("rejected".to_string()))); + assert!(matches!(rejected, Err(Error::Generic(_)))); + } +} diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 57557093aea..b6b36194a90 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -961,6 +961,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_parseIn inviter_username: ptr::null_mut(), amount_duffs: 0, expiry_unix: 0, + inviter_display_name: ptr::null_mut(), + inviter_avatar_url: ptr::null_mut(), }; let result = unsafe { platform_wallet_ffi::platform_wallet_parse_invitation(uri_c.as_ptr(), &mut preview) @@ -970,6 +972,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_parseIn } let username = unsafe { opt_cstr(preview.inviter_username) }; unsafe { platform_wallet_ffi::platform_wallet_string_free(preview.inviter_username) }; + // Not surfaced to Kotlin yet, but owned by us once the parse returns. + unsafe { platform_wallet_ffi::platform_wallet_string_free(preview.inviter_display_name) }; + unsafe { platform_wallet_ffi::platform_wallet_string_free(preview.inviter_avatar_url) }; let username_json = username .as_deref() .map(json_string) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 917519ee3cd..af4ad3fa75a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2185,10 +2185,51 @@ extension ManagedPlatformWallet { /// contact-bootstrap precondition (may be nil even when `hasInviter`). public let inviterUsername: String? /// Always 0: the amount isn't in the link (it carries the funding txid, - /// not the proof) and is only known after the tx is fetched at claim time. + /// not the proof). Read it with ``invitationClaimStatus(uri:)``. public let amountDuffs: UInt64 /// Always 0: the legacy link carries no expiry field. public let expiryUnix: UInt32 + /// Inviter display name (`display-name`) when the link carried one. + public let inviterDisplayName: String? + /// Inviter avatar URL (`avatar-url`, percent-decoded) when the link + /// carried one. Unvalidated — treat as untrusted input. + public let inviterAvatarURL: String? + } + + /// The invitee's pre-claim view of an invitation, from + /// ``invitationClaimStatus(uri:)``. + public struct InvitationClaimStatus: Sendable, Equatable { + /// The identity the claim would create (32 bytes). + public let prospectiveIdentityId: Data + /// Value of the credit output the voucher key controls (duffs). This is + /// the tier signal: the link does not say whether it funds a contested + /// or a non-contested username, the amount the inviter locked does. + public let amountDuffs: UInt64 + /// The claim would submit an InstantSend proof. + public let isInstant: Bool + /// The funding transaction is chain-locked. `false` together with + /// `isInstant == false` is a ChainLock-only invitation that cannot be + /// claimed until its funding transaction is chain-locked. + public let isChainLocked: Bool + /// An identity already exists at `prospectiveIdentityId` — the + /// invitation was claimed. `false` does NOT prove the voucher is + /// unspent (a reclaim top-up consumes it without creating this + /// identity), so the claim can still fail late. + public let alreadyClaimed: Bool + + public init( + prospectiveIdentityId: Data, + amountDuffs: UInt64, + isInstant: Bool, + isChainLocked: Bool, + alreadyClaimed: Bool + ) { + self.prospectiveIdentityId = prospectiveIdentityId + self.amountDuffs = amountDuffs + self.isInstant = isInstant + self.isChainLocked = isChainLocked + self.alreadyClaimed = alreadyClaimed + } } /// Create a DashPay invitation (DIP-13): fund a one-time asset-lock voucher @@ -2364,26 +2405,60 @@ extension ManagedPlatformWallet { /// transaction is refetched to locate the credit output the voucher /// controls. It claims nothing and mutates no wallet state. /// - /// Throws on anything undetermined — wrong network, a funding tx that has - /// not propagated, transport failure. Callers must treat a throw as - /// "proceed", never as an answer either way. + /// Two throws are definitive: a malformed link (`invalidParameter`) and a + /// link for the other network (`invalidNetwork`) can never be claimed by + /// this wallet. Every other throw is undetermined (a funding tx that has + /// not propagated, transport failure) and must be treated as "proceed", + /// never as an answer either way. public func invitationProspectiveIdentityId(uri: String) async throws -> Data { - let handle = self.handle - return try await Task.detached(priority: .userInitiated) { () -> Data in - var idTuple: ( - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 - ) = ( - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - ) - let result = uri.withCString { uriPtr in - platform_wallet_invitation_prospective_identity_id(handle, uriPtr, &idTuple) + // `self` stays alive for the whole call: a deinit mid-call would + // destroy the handle the FFI is still using. + return try await Task.detached(priority: .userInitiated) { [self] () -> Data in + try withExtendedLifetime(self) { + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + let result = uri.withCString { uriPtr in + platform_wallet_invitation_prospective_identity_id(handle, uriPtr, &idTuple) + } + try result.check() + return withUnsafeBytes(of: idTuple) { Data($0) } + } + }.value + } + + /// What an invitation is worth and whether it was already claimed, without + /// claiming it — one funding-tx fetch and one identity fetch. + /// + /// Same error contract as ``invitationProspectiveIdentityId(uri:)``: a + /// malformed link (`ErrorInvalidParameter`) and a link for the other + /// network (`ErrorInvalidNetwork`) are definitive; every other throw is + /// undetermined (not propagated yet, transport failure) and must not be + /// read as an answer either way. + public func invitationClaimStatus(uri: String) async throws -> InvitationClaimStatus { + // `self` stays alive for the whole call (up to ~12 s of funding-tx + // retries): a deinit mid-call would destroy the handle in use. + return try await Task.detached(priority: .userInitiated) { [self] () -> InvitationClaimStatus in + try withExtendedLifetime(self) { + var out = InvitationClaimStatusFFI() + let result = uri.withCString { uriPtr in + platform_wallet_invitation_claim_status(handle, uriPtr, &out) + } + try result.check() + return InvitationClaimStatus( + prospectiveIdentityId: withUnsafeBytes(of: out.prospective_identity_id) { Data($0) }, + amountDuffs: out.amount_duffs, + isInstant: out.is_instant, + isChainLocked: out.is_chain_locked, + alreadyClaimed: out.already_claimed + ) } - try result.check() - return withUnsafeBytes(of: idTuple) { Data($0) } }.value } @@ -2401,12 +2476,18 @@ extension ManagedPlatformWallet { platform_wallet_parse_invitation(uriPtr, &out) } try result.check() - // The Rust side heap-allocates the username C string when the link - // carries an inviter; free it once we've copied it into Swift. + // The Rust side heap-allocates the inviter C strings when the link + // carries them; free them once we've copied them into Swift. defer { if out.inviter_username != nil { platform_wallet_string_free(out.inviter_username) } + if out.inviter_display_name != nil { + platform_wallet_string_free(out.inviter_display_name) + } + if out.inviter_avatar_url != nil { + platform_wallet_string_free(out.inviter_avatar_url) + } } // Always nil, matching the documented contract: the legacy link // carries no inviter identity id (the ABI's `inviter_id` is @@ -2421,7 +2502,9 @@ extension ManagedPlatformWallet { inviterId: inviterId, inviterUsername: inviterUsername, amountDuffs: out.amount_duffs, - expiryUnix: out.expiry_unix + expiryUnix: out.expiry_unix, + inviterDisplayName: out.inviter_display_name.map { String(cString: $0) }, + inviterAvatarURL: out.inviter_avatar_url.map { String(cString: $0) } ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPreviewTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPreviewTests.swift new file mode 100644 index 00000000000..8285618fb90 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPreviewTests.swift @@ -0,0 +1,58 @@ +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +/// The invitee's read-only view of a `dashpay://invite` link: the offline +/// preview carries the inviter metadata the claim UI shows, and the networked +/// claim-status call refuses a malformed link definitively rather than +/// reporting it as an unclaimed invitation. +final class InvitationPreviewTests: XCTestCase { + + /// Testnet WIF for the secret `0x11 × 32` (compressed). + private static let testnetWif = "cN9spWsvaxA8taS7DFMxnk1yJD2gaF2PX1npuTpy3vuZFJdwavaw" + private static let txid = String(repeating: "ab", count: 32) + + private func wallet() -> ManagedPlatformWallet { + // The preview never touches the handle; the status call rejects a + // malformed link before any wallet lookup. + ManagedPlatformWallet(handle: NULL_HANDLE, walletId: Data()) + } + + func testPreviewSurfacesInviterDisplayNameAndAvatar() throws { + let uri = "dashpay://invite?du=alice&assetlocktx=\(Self.txid)&pk=\(Self.testnetWif)" + + "&islock=null&display-name=Alice%20B&avatar-url=https%3A%2F%2Fexample.org%2Fa.png" + let preview = try wallet().parseInvitation(uri: uri) + XCTAssertTrue(preview.structurallyValid) + XCTAssertEqual(preview.inviterUsername, "alice") + XCTAssertEqual(preview.inviterDisplayName, "Alice B") + XCTAssertEqual(preview.inviterAvatarURL, "https://example.org/a.png") + XCTAssertEqual(preview.amountDuffs, 0, "the amount is a network fact, not in the link") + } + + func testPreviewWithoutMetadataHasNilDisplayNameAndAvatar() throws { + let uri = "dashpay://invite?du=alice&assetlocktx=\(Self.txid)&pk=\(Self.testnetWif)&islock=null" + let preview = try wallet().parseInvitation(uri: uri) + XCTAssertTrue(preview.structurallyValid) + XCTAssertEqual(preview.inviterUsername, "alice") + XCTAssertNil(preview.inviterDisplayName) + XCTAssertNil(preview.inviterAvatarURL) + } + + func testMalformedPreviewIsInvalidNotThrown() throws { + let preview = try wallet().parseInvitation(uri: "https://not-an-invite") + XCTAssertFalse(preview.structurallyValid) + XCTAssertNil(preview.inviterDisplayName) + XCTAssertNil(preview.inviterAvatarURL) + } + + func testClaimStatusRejectsMalformedLinkAsInvalidParameter() async { + do { + _ = try await wallet().invitationClaimStatus(uri: "https://not-an-invite") + XCTFail("a malformed link must throw, never report an unclaimed invitation") + } catch PlatformWalletError.invalidParameter { + // Definitive: there is no invitation to claim. + } catch { + XCTFail("expected invalidParameter, got \(error)") + } + } +}