diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index ed01ce6901..02ead59d8f 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -29,7 +29,9 @@ use std::ffi::CStr; use std::os::raw::c_char; use dpp::identity::accessors::IdentityGettersV0; -use platform_wallet::wallet::identity::crypto::{parse_invitation_uri, InviterInfo}; +use platform_wallet::wallet::identity::crypto::{ + parse_invitation_uri, wif_network_matches, InviterInfo, +}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use platform_wallet::wallet::identity::network::MAX_INVITATION_TTL_SECS; @@ -314,6 +316,113 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( PlatformWalletFFIResult::ok() } +/// The identity id this invitation WOULD create — a read-only probe that lets +/// the UI reject an already-claimed voucher up front. +/// +/// Platform derives a created identity's id from the asset-lock outpoint, so +/// the caller can ask "does an identity already exist under this id?" (a plain +/// identity fetch) and answer "has this invitation been used?" without +/// attempting the claim. Without it, a spent voucher only surfaces at the very +/// end of registration as a raw "asset lock … already completely used", after +/// the invitee has chosen a username and entered their PIN. +/// +/// Unlike [`platform_wallet_parse_invitation`] this DOES hit the network: the +/// funding transaction has to be refetched to locate the credit output the +/// voucher controls (it need not be output 0). It still claims nothing and +/// mutates no wallet state. +/// +/// # What an identity id does and does not tell you +/// +/// An identity existing under the returned id means the voucher was +/// **definitely** claimed. Its absence does **not** mean it is usable: the same +/// lock can be consumed by `IdentityTopUp` (the reclaim path behind +/// [`platform_wallet_topup_identity_with_existing_asset_lock_signer`] with +/// `consume_invitation_voucher: true`), which credits an existing identity +/// instead of creating this one. Platform exposes no client query for spent +/// asset locks, so that case is undetectable here. Reject on "identity exists"; +/// otherwise proceed without concluding the voucher is good. +/// +/// # Errors are NOT uniformly undetermined +/// +/// Two failures are definitive and mean the link can never be claimed by this +/// wallet — the caller should surface them, not proceed: +/// +/// * `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. +/// +/// Every other failure (funding-tx not yet propagated, transport error) leaves +/// usability genuinely undetermined, and only those should be treated as +/// "proceed". +/// +/// # Safety +/// - `uri` must be a valid NUL-terminated UTF-8 C string. +/// - `out_identity_id` must be a valid `*mut [u8; 32]`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_invitation_prospective_identity_id( + wallet_handle: Handle, + 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]; + } + + 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) { + Ok(invitation) => invitation, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invitation link is malformed and cannot be claimed: {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) + }, + ); + let result = unwrap_option_or_return!(option); + let identifier = match result { + Ok(identifier) => identifier, + Err(e) => return e, + }; + unsafe { + *out_identity_id = identifier.to_buffer(); + } + PlatformWalletFFIResult::ok() +} + /// Read-only preview of a `dashpay://invite` link — decode + surface the /// invitation's metadata WITHOUT claiming it (no wallet handle, no network, no /// side effects). The claim UI uses this to show the amount, sender, and expiry diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index caad079e0e..760b039bec 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -378,6 +378,58 @@ impl IdentityWallet { }) } + /// The identity id this invitation WOULD create, without claiming it. + /// + /// Platform derives a created identity's id from the asset-lock outpoint, + /// so the id is knowable before the claim. The claim is otherwise the only + /// way to learn a voucher is spent, which is why a used invitation surfaces + /// as a raw "asset lock … already completely used" after the invitee has + /// picked a username and entered their PIN. + /// + /// # Detects claims, not consumption — the signal is ONE-WAY + /// + /// An identity existing under the returned id means the voucher was + /// **definitely** claimed. Its absence does **not** mean the voucher is + /// usable. + /// + /// The lock can also be consumed by `IdentityTopUp` — the reclaim path + /// behind `platform_wallet_topup_identity_with_existing_asset_lock_signer` + /// with `consume_invitation_voucher: true` — which credits an EXISTING + /// identity rather than creating the derived one. Afterwards no identity + /// exists at this id, yet a claim still fails deterministically because the + /// asset-lock output is already spent. + /// + /// Platform exposes no client query for spent asset locks (drive tracks + /// them under `SpentAssetLockTransactions`, but no DAPI endpoint surfaces + /// it), so consumption cannot be checked from here. Callers must therefore + /// treat this as a fast-fail for the common case only: reject the + /// invitation when an identity exists, and otherwise proceed WITHOUT + /// concluding the voucher is usable. It narrows when the late failure + /// happens; it does not remove it. + /// + /// Costs one funding-tx fetch: the outpoint is not in the link (the credit + /// output is selected by pk↔script match, not by index), so the tx has to + /// be refetched exactly as the claim does. Same wrong-network fail-fast as + /// [`Self::claim_invitation`], so a testnet link on mainnet reports the + /// network mismatch rather than a confusing fetch miss. + pub async fn invitation_prospective_identity_id( + &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?; + proof.create_identifier().map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "invitation asset lock proof yielded no identity id: {e}" + )) + }) + } + /// Claim a DashPay invitation: register a NEW identity for the invitee, /// funded by the imported voucher. /// @@ -814,6 +866,101 @@ mod tests { assert!(matches!(proof, AssetLockProof::Chain(_))); } + /// The prospective identity id is derived from the *selected* credit + /// output's outpoint. Selection itself is pinned next to + /// `voucher_output_index`; what matters here is that the id follows it — a + /// voucher sitting behind a decoy must not yield the index-0 id, or the + /// "has this invitation been used?" check answers about a stranger's + /// identity and reports a perfectly good voucher as spent. + #[test] + fn prospective_id_follows_the_selected_credit_output() { + let key = voucher_secret(); + let decoy = SecretKey::from_slice(&[0x22u8; 32]).unwrap(); + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![ + TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(&decoy), + }, + TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(&key), + }, + ], + }; + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + }; + let txid = tx.txid(); + let inv = parsed(key, txid.to_string(), None); + + let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + let id = proof.create_identifier().unwrap(); + + let from_index_0 = + ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); + let from_index_1 = + ChainAssetLockProof::new(100, OutPoint::new(txid, 1).into()).create_identifier(); + + assert_ne!(id, from_index_0, "id must not come from credit output 0"); + assert_eq!(id, from_index_1); + } + + /// The prospective id is a pure function of the asset-lock OUTPOINT, so it + /// carries no information about whether that lock has been consumed. + /// + /// This is why the claimed-check is one-way. A voucher claimed normally + /// creates the identity at this id, and the check sees it. A voucher + /// RECLAIMED into an existing identity — `IdentityTopUp` via + /// `platform_wallet_topup_identity_with_existing_asset_lock_signer` with + /// `consume_invitation_voucher: true` — spends the very same outpoint but + /// creates nothing here, so the check still finds no identity while a claim + /// would fail deterministically. + /// + /// Pinned as an executable fact because the id derivation is what a reader + /// would otherwise assume encodes "spent": it does not, and Platform + /// exposes no client query for spent asset locks to fill the gap. + #[test] + fn prospective_id_is_outpoint_derived_and_says_nothing_about_consumption() { + let key = voucher_secret(); + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(&key), + }], + }; + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + }; + let txid = tx.txid(); + let inv = parsed(key, txid.to_string(), None); + + let id = assemble_asset_lock_proof(tx, true, 100, &inv) + .unwrap() + .create_identifier() + .unwrap(); + + // Nothing but (txid, vout) feeds it — the same value a reclaim would + // leave behind untouched. + let from_outpoint = + ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); + assert_eq!( + id, from_outpoint, + "the id must be derivable from the outpoint alone, which is exactly \ + why its absence cannot prove the lock is unspent" + ); + } + /// 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 73bb1f8db7..dbd1e166af 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2200,6 +2200,41 @@ extension ManagedPlatformWallet { }.value } + /// The identity id this invitation WOULD create, without claiming it. + /// + /// Platform derives a created identity's id from the asset-lock outpoint, + /// so fetching an identity under the returned id answers "has this + /// invitation already been used?" before the invitee picks a username and + /// enters their PIN — the only other way to find out is the claim itself, + /// which reports it as a raw "asset lock … already completely used". + /// + /// Unlike ``parseInvitation(uri:)`` this hits the network: the funding + /// 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. + 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) + } + try result.check() + return withUnsafeBytes(of: idTuple) { Data($0) } + }.value + } + /// Read-only preview of a DashPay invitation link (DIP-13): decode a /// `dashpay://invite` URI and surface its metadata WITHOUT claiming it — no /// network, no identity registered. The claim UI uses this to show the