From 93a759a6ed272a2adae587593f771c76b10273f0 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:17:34 +0300 Subject: [PATCH 1/4] feat(platform-wallet): expose an invitation's prospective identity id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spent DIP-13 voucher was only discoverable by attempting the claim: it surfaced as a raw "asset lock ... output N already completely used" after the invitee had picked a username and entered their PIN. Nothing lets a caller ask "is this invitation still good?" up front — there is no asset-lock-consumption query on the platform gRPC surface. Platform derives a created identity's id from the asset-lock outpoint, so the id an invitation *would* produce is knowable before the claim, and an identity already existing under it is exactly the "spent" signal. Adds `IdentityWallet::invitation_prospective_identity_id` (reusing the claim's own proof reconstruction, so the credit output is selected by pk-to-script match rather than assumed to be index 0) and the FFI/Swift wrappers. It hits the network — the funding transaction has to be refetched — but claims nothing and mutates no wallet state. Any failure is genuinely undetermined (wrong network, tx not yet propagated, transport error), which the docs state explicitly: callers must treat an error as "proceed", never as an answer either way. --- .../rs-platform-wallet-ffi/src/invitation.rs | 54 +++++++++++++++++++ .../src/wallet/identity/network/invitation.rs | 33 ++++++++++++ .../ManagedPlatformWallet.swift | 35 ++++++++++++ 3 files changed, 122 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index ed01ce69013..732deab4971 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -314,6 +314,60 @@ 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. +/// +/// A failure here is genuinely undetermined — a wrong-network link, a tx that +/// has not propagated, a transport error — so callers must treat any error as +/// "proceed", never as "unclaimed" or "claimed". +/// +/// # 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()); + let invitation = unwrap_result_or_return!(parse_invitation_uri(uri)); + + 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 = unwrap_result_or_return!(result); + 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 caad079e0e2..68bfff2f38f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -403,6 +403,39 @@ impl IdentityWallet { /// The contact-bootstrap is a separate step: on success the UI asks the /// invitee whether to establish contact with the sender and, if so, calls /// the existing contact-request path. + /// 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 — and an identity already + /// existing under it is exactly the "this voucher has been spent" signal. + /// The claim itself is the only other way to learn that, which is why a + /// used invitation otherwise surfaces as a raw "asset lock … already + /// completely used" after the invitee has picked a username and entered + /// their PIN. + /// + /// 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}" + )) + }) + } + pub async fn claim_invitation( &self, invitation: ParsedInvitation, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 73bb1f8db73..82fb8044633 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2208,6 +2208,41 @@ extension ManagedPlatformWallet { /// /// A malformed link is reported as `structurallyValid == false` rather than /// throwing, so the UI can render a clean "invalid link" state. + /// 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 + } + public func parseInvitation(uri: String) throws -> InvitationPreview { var out = InvitationPreviewFFI() let result = uri.withCString { uriPtr in From 2efd08e054458188c0c79a18a6e59cb75e76ed1d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:40:12 +0300 Subject: [PATCH 2/4] docs(platform-wallet): restore the docs the new API was spliced into The prospective-id declaration landed in the middle of the doc block above it, so `claim_invitation` and `parseInvitation(uri:)` lost their documentation entirely and the new API inherited it as a prefix. Move each block back onto the declaration it describes. --- .../src/wallet/identity/network/invitation.rs | 50 +++++++++---------- .../ManagedPlatformWallet.swift | 16 +++--- 2 files changed, 33 insertions(+), 33 deletions(-) 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 68bfff2f38f..874ffb3e0e3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -378,31 +378,6 @@ impl IdentityWallet { }) } - /// Claim a DashPay invitation: register a NEW identity for the invitee, - /// funded by the imported voucher. - /// - /// The link carries only the voucher key + funding txid (+ optional islock), - /// not the funding proof — so this **refetches** the funding transaction - /// from Core and reconstructs the asset-lock proof, mirroring the legacy - /// Android claim (`TopUpRepository.obtainAssetLockTransaction`): - /// 1. Fetch the tx by `funding_txid`; retry byte-reversed on a miss (old iOS - /// links are little-endian). - /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an - /// islock is present) that the islock locks it. - /// 3. Select the funded credit output by pk↔script match (not index 0). - /// 4. Build an `InstantAssetLockProof` when an islock is present, else a - /// `ChainAssetLockProof` once the funding tx is chain-locked. - /// - /// The invitee's own identity keys (`keys_map`, derived from the invitee's - /// seed) are signed by `identity_signer`; the asset-lock's outer - /// state-transition signature is produced from the **imported voucher key** - /// (`invitation.voucher_key`) via the SDK's in-process raw-key path. The - /// invitee owns neither the lock's inputs nor its tracking, so this bypasses - /// the wallet's `AssetLockFunding` machinery entirely. - /// - /// The contact-bootstrap is a separate step: on success the UI asks the - /// invitee whether to establish contact with the sender and, if so, calls - /// the existing contact-request path. /// The identity id this invitation WOULD create, without claiming it. /// /// Platform derives a created identity's id from the asset-lock outpoint, @@ -436,6 +411,31 @@ impl IdentityWallet { }) } + /// Claim a DashPay invitation: register a NEW identity for the invitee, + /// funded by the imported voucher. + /// + /// The link carries only the voucher key + funding txid (+ optional islock), + /// not the funding proof — so this **refetches** the funding transaction + /// from Core and reconstructs the asset-lock proof, mirroring the legacy + /// Android claim (`TopUpRepository.obtainAssetLockTransaction`): + /// 1. Fetch the tx by `funding_txid`; retry byte-reversed on a miss (old iOS + /// links are little-endian). + /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an + /// islock is present) that the islock locks it. + /// 3. Select the funded credit output by pk↔script match (not index 0). + /// 4. Build an `InstantAssetLockProof` when an islock is present, else a + /// `ChainAssetLockProof` once the funding tx is chain-locked. + /// + /// The invitee's own identity keys (`keys_map`, derived from the invitee's + /// seed) are signed by `identity_signer`; the asset-lock's outer + /// state-transition signature is produced from the **imported voucher key** + /// (`invitation.voucher_key`) via the SDK's in-process raw-key path. The + /// invitee owns neither the lock's inputs nor its tracking, so this bypasses + /// the wallet's `AssetLockFunding` machinery entirely. + /// + /// The contact-bootstrap is a separate step: on success the UI asks the + /// invitee whether to establish contact with the sender and, if so, calls + /// the existing contact-request path. pub async fn claim_invitation( &self, invitation: ParsedInvitation, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 82fb8044633..dbd1e166af4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2200,14 +2200,6 @@ extension ManagedPlatformWallet { }.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 - /// amount, sender, and expiry before the user commits, and to decide whether - /// to offer the "establish contact with ?" bootstrap. - /// - /// A malformed link is reported as `structurallyValid == false` rather than - /// throwing, so the UI can render a clean "invalid link" state. /// The identity id this invitation WOULD create, without claiming it. /// /// Platform derives a created identity's id from the asset-lock outpoint, @@ -2243,6 +2235,14 @@ extension ManagedPlatformWallet { }.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 + /// amount, sender, and expiry before the user commits, and to decide whether + /// to offer the "establish contact with ?" bootstrap. + /// + /// A malformed link is reported as `structurallyValid == false` rather than + /// throwing, so the UI can render a clean "invalid link" state. public func parseInvitation(uri: String) throws -> InvitationPreview { var out = InvitationPreviewFFI() let result = uri.withCString { uriPtr in From 7aa22f449122cf748b41f689b3666eefc41f2799 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:44:12 +0300 Subject: [PATCH 3/4] test(platform-wallet): pin the prospective id to the selected credit output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output selection is already covered next to `voucher_output_index`; what was untested is that the derived id follows that selection. A voucher behind a decoy output must not produce the index-0 id — that would make the claimed-check answer about a stranger's identity and report a good voucher as spent. --- .../src/wallet/identity/network/invitation.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 874ffb3e0e3..6a2802931e6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -847,6 +847,51 @@ 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); + } + /// 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] From 2e680ae6ed626ed5c16df9d8ed91c2850813ec48 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 15:57:07 +0700 Subject: [PATCH 4/4] fix(invitation): limit the claimed-precheck contract; distinguish definitive errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups. **The precheck is one-way (blocking #1).** An identity at the derived id proves the voucher was claimed; its absence does NOT prove 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 and creates nothing at this id, so the check passes while a claim still fails deterministically. 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 here. Rather than imply a guarantee it cannot make, the contract now says so on both the library method and the FFI: reject on "identity exists", otherwise proceed WITHOUT concluding the voucher is good. It narrows the window for the late failure; it does not remove it. Covered by a test pinning the property that makes it one-way: the id is a pure function of the asset-lock outpoint, so it is unchanged by a reclaim that spends that same outpoint. That is the assumption a reader would otherwise make about the id encoding "spent". **Definitive errors are no longer reported as undetermined (blocking #2).** Two failures mean the link can never be claimed by this wallet, and both previously reached Swift through the catch-all while the docs said to ignore every error and proceed — steering users into a claim already known to fail: * malformed URI -> `ErrorInvalidParameter` * wrong network -> `ErrorInvalidNetwork` (checked at the FFI, as the withdrawal FFI does, so it gets a distinguishable code; `claim_invitation` applies the same guard and would refuse it too) Everything else — funding-tx propagation lag, transport failures — remains genuinely undetermined, and only those should be treated as "proceed". The FFI doc now separates the two classes instead of flattening them. Co-Authored-By: Claude Opus 5 --- .../rs-platform-wallet-ffi/src/invitation.rs | 83 +++++++++++++++---- .../src/wallet/identity/network/invitation.rs | 81 ++++++++++++++++-- 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 732deab4971..02ead59d8f5 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; @@ -329,9 +331,30 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( /// voucher controls (it need not be output 0). It still claims nothing and /// mutates no wallet state. /// -/// A failure here is genuinely undetermined — a wrong-network link, a tx that -/// has not propagated, a transport error — so callers must treat any error as -/// "proceed", never as "unclaimed" or "claimed". +/// # 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. @@ -350,18 +373,50 @@ pub unsafe extern "C" fn platform_wallet_invitation_prospective_identity_id( } let uri = unwrap_result_or_return!(unsafe { CStr::from_ptr(uri) }.to_str()); - let invitation = unwrap_result_or_return!(parse_invitation_uri(uri)); + // 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| { - let identity_wallet = wallet.identity().clone(); - block_on_worker(async move { - identity_wallet - .invitation_prospective_identity_id(&invitation) - .await - }) - }); + 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 = unwrap_result_or_return!(result); + let identifier = match result { + Ok(identifier) => identifier, + Err(e) => return e, + }; unsafe { *out_identity_id = identifier.to_buffer(); } 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 6a2802931e6..760b039bec0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -381,12 +381,31 @@ 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 — and an identity already - /// existing under it is exactly the "this voucher has been spent" signal. - /// The claim itself is the only other way to learn that, which is why a - /// used invitation otherwise surfaces as a raw "asset lock … already - /// completely used" after the invitee has picked a username and entered - /// their PIN. + /// 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 @@ -892,6 +911,56 @@ mod tests { 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]