Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion packages/rs-platform-wallet-ffi/src/invitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<dash_sdk::platform::Identifier, PlatformWalletFFIResult> {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
&self,
invitation: &ParsedInvitation,
) -> Result<Identifier, PlatformWalletError> {
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}"
))
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Claim a DashPay invitation: register a NEW identity for the invitee,
/// funded by the imported voucher.
///
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading