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 760b039bec..f9fde3850c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -36,7 +36,8 @@ use crate::changeset::{ use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; -use crate::error::PlatformWalletError; +use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError}; +use crate::wallet::asset_lock::orchestration::submit_with_cl_height_retry; use crate::wallet::identity::crypto::{ encode_invitation_uri, voucher_output_index, wif_network_matches, }; @@ -423,7 +424,11 @@ impl IdentityWallet { ))); } let proof = self.reconstruct_asset_lock_proof(invitation).await?; - proof.create_identifier().map_err(|e| { + // 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}" )) @@ -442,8 +447,18 @@ impl IdentityWallet { /// 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. + /// 4. Build an `InstantAssetLockProof` when an islock is present (the fast + /// path), else a `ChainAssetLockProof` once the funding tx is chain-locked. + /// + /// The IS proof is the *fast* path only: a voucher islock is signed at + /// creation, so by the time the invitee claims (minutes-to-hours later, and on + /// testnet possibly across a platform-4.1 quorum rotation) Drive may reject the + /// islock as stale with `InvalidInstantAssetLockProofSignatureError` — its own + /// message asks to "try chain asset lock proof instead". When the funding tx is + /// already chain-locked (the usual case by claim time), this resubmits a + /// `ChainAssetLockProof` over the SAME credit output, mirroring the IS→CL + /// fallback the register/top-up paths use. If the tx is not yet chain-locked no + /// fallback is possible and the claim surfaces a retry signal. /// /// The invitee's own identity keys (`keys_map`, derived from the invitee's /// seed) are signed by `identity_signer`; the asset-lock's outer @@ -482,7 +497,16 @@ impl IdentityWallet { // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local // guards below are for fast-fail + correct-index selection, not theft // prevention (a crafted link at worst yields a failed claim). - let asset_lock = self.reconstruct_asset_lock_proof(&invitation).await?; + // + // `primary` is submitted first: an InstantSend proof when the link carried + // an islock (the fast path), or a ChainLock proof when it did not. + // `chain_fallback` is a ChainLock proof over the SAME credit output, + // populated only when `primary` is InstantSend AND the funding tx is + // already chain-locked — the stale-islock recovery below resubmits it. + let ReconstructedProof { + primary, + chain_fallback, + } = self.reconstruct_asset_lock_proof(&invitation).await?; // The voucher key signs the asset lock's outer ST signature (ECDSA over // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, @@ -490,6 +514,8 @@ impl IdentityWallet { let network = self.sdk.network; let voucher_priv = WipingPrivateKey(PrivateKey::new(invitation.voucher_key, network)); + // Build the placeholder identity ONCE so both the primary attempt and the + // IS→CL fallback submit the same key set without a `keys_map` clone. let placeholder = Identity::V0(IdentityV0 { id: Identifier::default(), public_keys: keys_map, @@ -497,19 +523,33 @@ impl IdentityWallet { revision: 0, }); - // Submit directly. An InstantSend or ChainLock proof both prove finality; - // a proof that no longer applies (e.g. the invite was already claimed) is - // rejected by consensus and surfaced to the caller. - let identity = placeholder - .put_to_platform_and_wait_for_response_with_private_key( - &self.sdk, - asset_lock, - &voucher_priv.0, - identity_signer, - settings, - ) - .await - .map_err(PlatformWalletError::Sdk)?; + // Submit through the stale-islock fallback seam: the primary proof + // first and — only on Platform's stale-islock rejection — the ChainLock + // fallback over the SAME credit output (or `AssetLockNotChainLocked` + // when none could be built). Each submission is wrapped in the shared + // CL-height-too-low retry (Platform's observed Core tip briefly behind + // the proof's chain-locked height — the same transient the + // register/top-up paths absorb; harmless for an IS proof, which never + // triggers it). This mirrors `register_identity_with_funding`'s IS→CL + // fallback, except the CL proof was rebuilt from the refetched tx: the + // invitee tracks no asset lock of its own, so there is no + // `upgrade_to_chain_lock_proof` to call. + let sdk = &self.sdk; + let placeholder = &placeholder; + let voucher_priv = &voucher_priv; + let identity = + 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( + sdk, + proof.clone(), + &voucher_priv.0, + identity_signer, + s, + ) + }) + }) + .await?; // Best-effort local bookkeeping — Platform has already accepted the // registration, so a local failure must NOT propagate (mirrors @@ -582,10 +622,15 @@ impl IdentityWallet { /// tx, select the voucher's credit output, and assemble an InstantSend proof /// (when the link carried an islock) or a ChainLock proof (islock absent / /// `"null"` — a chainlock-confirmed invite). + /// + /// Returns a [`ReconstructedProof`]: the `primary` proof to submit plus an + /// optional `chain_fallback` ChainLock proof used by the caller's stale-islock + /// recovery (populated only when the primary is InstantSend and the funding tx + /// is already chain-locked). async fn reconstruct_asset_lock_proof( &self, invitation: &ParsedInvitation, - ) -> Result { + ) -> Result { let sdk = &self.sdk; let fetched = fetch_funding_tx_with_retry( &invitation.funding_txid, @@ -661,18 +706,88 @@ where Ok(None) } +/// Submit the claim's reconstructed proof with the stale-islock → ChainLock +/// fallback — the injectable orchestration seam of the claim submission (tests +/// script `submit`; production passes the placeholder-identity put wrapped in +/// `submit_with_cl_height_retry`). +/// +/// `primary` is submitted first. Exactly ONE recovery is attempted, and only +/// for the one rejection that indicts the proof kind rather than the claim: +/// `is_instant_lock_proof_invalid` (Platform rejected a stale islock — its +/// signing quorum rotated out, or it is no longer "recent"; Platform's own +/// message asks us to "try chain asset lock proof instead"). On that rejection +/// the ChainLock `chain_fallback` over the SAME credit output is submitted — +/// or, when the funding tx was not yet chain-locked so no fallback could be +/// built, `AssetLockNotChainLocked` surfaces a clear retry signal instead of +/// the raw consensus error. Every other error — from the primary or from the +/// fallback itself — propagates as [`PlatformWalletError::Sdk`] with no +/// further submission. +async fn submit_claim_with_stale_islock_fallback( + primary: AssetLockProof, + chain_fallback: Option, + mut submit: F, +) -> Result +where + F: FnMut(AssetLockProof) -> Fut, + Fut: std::future::Future>, +{ + match submit(primary).await { + Ok(created) => Ok(created), + Err(e) if is_instant_lock_proof_invalid(&e) => { + let Some(chain_proof) = chain_fallback else { + return Err(PlatformWalletError::AssetLockNotChainLocked( + "invitation islock proof was rejected by Platform (stale — quorum \ + rotated or no longer recent) and the funding transaction is not yet \ + chain-locked, so no ChainLock fallback is possible; retry once the \ + funding block is chain-locked" + .to_string(), + )); + }; + tracing::warn!( + "invitation IS-lock proof rejected by Platform on claim; retrying with a \ + ChainLock proof over the same funding outpoint" + ); + submit(chain_proof).await.map_err(PlatformWalletError::Sdk) + } + Err(e) => Err(PlatformWalletError::Sdk(e)), + } +} + +/// The claim's reconstructed funding proof, plus an optional ChainLock fallback. +/// +/// `primary` is submitted first: an [`AssetLockProof::Instant`] when the link +/// carried an islock (the fast path), or an [`AssetLockProof::Chain`] when it did +/// not. `chain_fallback` is a ChainLock proof over the SAME credit output, +/// populated ONLY when `primary` is InstantSend AND the funding tx is already +/// chain-locked — [`IdentityWallet::claim_invitation`]'s stale-islock recovery +/// resubmits it if Platform rejects the primary IS proof with +/// `InvalidInstantAssetLockProofSignatureError`. It is `None` when the primary is +/// already a ChainLock proof (nothing to fall back to) or when the funding tx is +/// not yet chain-locked (no ChainLock proof can be built — the claim must be +/// retried once the block confirms). +#[derive(Debug)] +struct ReconstructedProof { + primary: AssetLockProof, + 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, 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. @@ -690,6 +805,16 @@ fn assemble_asset_lock_proof( // — 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)) + }; + match &invitation.islock_hex { Some(islock_hex) => { let islock_bytes = hex::decode(islock_hex).map_err(|e| { @@ -711,17 +836,29 @@ fn assemble_asset_lock_proof( "invitation islock does not lock the funding transaction".to_string(), )); } - Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + // 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())); + let primary = AssetLockProof::Instant(InstantAssetLockProof::new( instant_lock, transaction, output_index, - ))) + )); + Ok(ReconstructedProof { + primary, + chain_fallback, + }) } None => { // ChainLock invite: the proof references the outpoint + a chain-locked // core height. Require the funding tx to be chain-locked so the proof // proves finality; the inviter/invitee retries once the block is - // chain-locked otherwise. + // chain-locked otherwise. There is no separate fallback — this IS the + // ChainLock proof. if !is_chain_locked { return Err(PlatformWalletError::InvalidIdentityData( "chainlock invitation funding transaction is not yet chain-locked; \ @@ -729,12 +866,10 @@ fn assemble_asset_lock_proof( .to_string(), )); } - let out_point = OutPoint::new(transaction.txid(), output_index); - let out_point_bytes: [u8; 36] = out_point.into(); - Ok(AssetLockProof::Chain(ChainAssetLockProof::new( - height, - out_point_bytes, - ))) + Ok(ReconstructedProof { + primary: chain_lock_proof(transaction.txid()), + chain_fallback: None, + }) } } } @@ -854,16 +989,68 @@ mod tests { assert!(format!("{err}").contains("not yet chain-locked")); } - /// A chain-locked ChainLock invite assembles a ChainLock proof at the tx's - /// voucher output. + /// A chain-locked ChainLock invite (no islock) assembles a ChainLock proof at + /// the tx's voucher output, with no separate fallback (the primary IS the CL + /// proof). #[test] fn assemble_chainlock_ok_when_locked() { let key = voucher_secret(); let tx = funding_tx(&key); let txid = tx.txid().to_string(); let inv = parsed(key, txid, None); - let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); - assert!(matches!(proof, AssetLockProof::Chain(_))); + let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!(matches!(reconstructed.primary, AssetLockProof::Chain(_))); + assert!( + reconstructed.chain_fallback.is_none(), + "an islock-less chainlock invite has no separate fallback" + ); + } + + /// An islock that locks the funding tx, with the tx already chain-locked, + /// yields an InstantSend primary (fast path) PLUS a ChainLock fallback over the + /// same credit output — the stale-islock recovery the claim path submits if + /// Platform rejects the IS proof with `InvalidInstantAssetLockProofSignatureError`. + #[test] + fn assemble_islock_present_and_chainlocked_carries_chain_fallback() { + 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 reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!( + matches!(reconstructed.primary, AssetLockProof::Instant(_)), + "the islock fast path must be tried first" + ); + assert!( + matches!(reconstructed.chain_fallback, Some(AssetLockProof::Chain(_))), + "a chain-locked funding tx must carry a ChainLock fallback for stale-islock recovery" + ); + } + + /// An islock that locks the funding tx, but the tx NOT yet chain-locked, yields + /// the IS primary with NO fallback: if that islock is later rejected there is + /// no ChainLock proof to fall back to, and the claim must be retried once the + /// block confirms. + #[test] + fn assemble_islock_present_not_chainlocked_has_no_fallback() { + 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 reconstructed = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap(); + assert!(matches!(reconstructed.primary, AssetLockProof::Instant(_))); + assert!( + reconstructed.chain_fallback.is_none(), + "an un-chain-locked funding tx cannot produce a ChainLock fallback" + ); } /// The prospective identity id is derived from the *selected* credit @@ -900,7 +1087,7 @@ mod tests { 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 id = proof.primary.create_identifier().unwrap(); let from_index_0 = ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); @@ -947,6 +1134,7 @@ mod tests { let id = assemble_asset_lock_proof(tx, true, 100, &inv) .unwrap() + .primary .create_identifier() .unwrap(); @@ -1120,6 +1308,170 @@ mod tests { } } + // --- submit_claim_with_stale_islock_fallback: the claim submission seam --- + + mod claim_submission { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use super::super::submit_claim_with_stale_islock_fallback; + use super::{funding_tx, voucher_secret}; + use crate::PlatformWalletError; + use dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; + use dpp::dashcore::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + + /// The one rejection the seam recovers from: Platform's stale-islock + /// consensus error, in the `Protocol(ConsensusError)` shape + /// `is_instant_lock_proof_invalid` matches. + fn stale_islock_rejection() -> dash_sdk::Error { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + InvalidInstantAssetLockProofSignatureError::new().into(), + ))) + } + + /// Any error the seam must NOT treat as a stale islock. + fn unrelated_sdk_error() -> dash_sdk::Error { + dash_sdk::Error::Config("simulated unrelated rejection".to_string()) + } + + /// An InstantSend primary — the variant is what the assertions key on, + /// so a scripted submission log reads as primary-vs-fallback directly. + fn instant_primary() -> AssetLockProof { + AssetLockProof::Instant(InstantAssetLockProof::new( + InstantLock::default(), + funding_tx(&voucher_secret()), + 0, + )) + } + + fn chain_fallback_proof() -> AssetLockProof { + AssetLockProof::Chain(ChainAssetLockProof::new(100, [0u8; 36])) + } + + /// Drive the seam with a scripted submit: each call records the proof + /// it was handed and pops the next scripted result. + async fn run( + chain_fallback: Option, + script: Vec>, + ) -> (Result, Vec) { + let queue: Arc>>> = + Arc::new(Mutex::new(script.into_iter().collect())); + let submitted: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (queue_f, submitted_f) = (Arc::clone(&queue), Arc::clone(&submitted)); + let result = submit_claim_with_stale_islock_fallback( + instant_primary(), + chain_fallback, + move |proof| { + let queue = Arc::clone(&queue_f); + let submitted = Arc::clone(&submitted_f); + async move { + submitted.lock().expect("submitted").push(proof); + queue + .lock() + .expect("script") + .pop_front() + .expect("script exhausted — the seam submitted more than scripted") + } + }, + ) + .await; + let recorded = submitted.lock().expect("submitted").clone(); + (result, recorded) + } + + /// A successful primary submission returns immediately: one submission, + /// and the ChainLock fallback is never touched even though it exists. + #[tokio::test] + async fn primary_success_never_submits_fallback() { + let (result, submitted) = run(Some(chain_fallback_proof()), vec![Ok(7)]).await; + assert_eq!(result.expect("primary success"), 7); + assert_eq!(submitted.len(), 1, "exactly one submission"); + assert!( + matches!(submitted[0], AssetLockProof::Instant(_)), + "the IS primary must be what was submitted" + ); + } + + /// A stale-islock rejection of the primary resubmits the ChainLock + /// fallback over the same outpoint, and the fallback's success is the + /// claim's success. + #[tokio::test] + async fn stale_islock_resubmits_chain_fallback() { + let (result, submitted) = run( + Some(chain_fallback_proof()), + vec![Err(stale_islock_rejection()), Ok(9)], + ) + .await; + assert_eq!(result.expect("fallback success"), 9); + assert_eq!(submitted.len(), 2, "primary then fallback"); + assert!( + matches!(submitted[0], AssetLockProof::Instant(_)), + "the IS primary must be tried first" + ); + assert!( + matches!(submitted[1], AssetLockProof::Chain(_)), + "the resubmission must carry the ChainLock fallback" + ); + } + + /// A stale-islock rejection with NO fallback (funding tx not yet + /// chain-locked) surfaces `AssetLockNotChainLocked` — the clear retry + /// signal — after exactly one submission. + #[tokio::test] + async fn stale_islock_without_fallback_surfaces_retry_signal() { + let (result, submitted) = run(None, vec![Err(stale_islock_rejection())]).await; + assert!( + matches!(result, Err(PlatformWalletError::AssetLockNotChainLocked(_))), + "expected the not-chain-locked retry signal, got {result:?}" + ); + assert_eq!( + submitted.len(), + 1, + "no second submission is possible without a fallback" + ); + } + + /// An unrelated SDK error propagates unchanged after one submission: + /// the fallback exists but must NOT be tried for it. + #[tokio::test] + async fn unrelated_error_propagates_without_second_submission() { + let (result, submitted) = run( + Some(chain_fallback_proof()), + vec![Err(unrelated_sdk_error())], + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::Sdk(_))), + "the unrelated error must propagate as Sdk, got {result:?}" + ); + assert_eq!( + submitted.len(), + 1, + "an unrelated error must not trigger the fallback" + ); + } + + /// The recovery is attempted exactly once: a fallback rejection — even + /// one wearing the stale-islock shape again — propagates rather than + /// looping. + #[tokio::test] + async fn fallback_rejection_is_not_recovered_again() { + let (result, submitted) = run( + Some(chain_fallback_proof()), + vec![Err(stale_islock_rejection()), Err(stale_islock_rejection())], + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::Sdk(_))), + "the fallback's own rejection must propagate as Sdk, got {result:?}" + ); + assert_eq!(submitted.len(), 2, "no third submission may follow"); + } + } + // --- create_invitation: the durable-persistence precondition --- mod durability_gate {