Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
54 changes: 54 additions & 0 deletions packages/rs-platform-wallet-ffi/src/invitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Definitive input errors are incorrectly documented as undetermined

Not every error from this function leaves the invitation's usability undetermined. parse_invitation_uri definitively rejects malformed links, while invitation_prospective_identity_id deterministically rejects a wallet-network mismatch; claim_invitation applies the same network guard, so the link cannot be claimed through the current wallet. Both currently reach Swift through the generic catch-all because InvalidIdentityData maps to ErrorUnknown, while the documentation tells callers to ignore every error and proceed. That sends users toward a claim that is already known to fail. Expose malformed-input and wrong-network failures through stable, distinguishable FFI/Swift result codes and reserve the “proceed because status is inconclusive” behavior for funding-transaction lookup and transport failures.

source: ['codex']

///
/// # 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,39 @@ 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Identity existence does not detect every consumed invitation

An identity under the derived outpoint ID is not an exact signal that the asset lock has been consumed. The supported invitation-reclaim path in platform_wallet_topup_identity_with_existing_asset_lock_signer explicitly authorizes an IdentityInvitation lock with consume_invitation_voucher: true and consumes it through IdentityTopUp, which credits an existing identity rather than creating the derived identity. After that reclaim, fetching the prospective ID still returns no identity even though a claim will deterministically fail because the asset-lock output is already consumed. This recreates the late failure the new precheck is intended to prevent. The API must either query actual asset-lock consumption or explicitly limit its contract and consumer behavior to detecting consumption through identity creation; add coverage for reclaiming an invitation into an existing identity.

source: ['codex']

/// 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(
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 +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]
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