From df2b745cd662ffba2b2b165d822ddb52194576a2 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:58:12 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(platform-wallet):=20stale-islock=20?= =?UTF-8?q?=E2=86=92=20ChainLock=20fallback=20for=20L1=20invite=20claim=20?= =?UTF-8?q?(#4240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voucher islock is signed at creation, so by claim time (minutes-to-hours later, possibly across a testnet platform-4.1 quorum rotation) Drive may reject it with InvalidInstantAssetLockProofSignatureError ("try chain asset lock proof instead"). claim_invitation now recovers exactly as the register/top-up paths do: - reconstruct_asset_lock_proof / assemble_asset_lock_proof return a ReconstructedProof { primary, chain_fallback }. When the link carried an islock AND the funding tx is already chain-locked, a ChainLock proof over the SAME credit output is built as chain_fallback. - The primary proof is submitted through submit_with_cl_height_retry. If it is an IS proof rejected with is_instant_lock_proof_invalid(), the ChainLock fallback is resubmitted over the same outpoint. If no fallback exists (tx not yet chain-locked), AssetLockNotChainLocked surfaces a clear retry signal. Adds unit tests for the three assemble outcomes (chainlock-only, islock+chainlocked→fallback, islock+not-chainlocked→no-fallback). Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/identity/network/invitation.rs | 221 +++++++++++++++--- 1 file changed, 193 insertions(+), 28 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 760b039bec..a9b02621e4 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, }; @@ -442,8 +443,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 +493,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 +510,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 +519,62 @@ 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( + // Submit the primary proof, 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). + // + // If Platform rejects the primary IS proof with + // `InvalidInstantAssetLockProofSignatureError` (the islock is stale — its + // signing quorum rotated out, or it is no longer "recent"; Platform's own + // message asks us to "try chain asset lock proof instead"), resubmit the + // ChainLock proof over the SAME credit output. This mirrors + // `register_identity_with_funding`'s IS→CL fallback, except the CL proof is + // 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 identity = match submit_with_cl_height_retry(settings, |s| { + placeholder.put_to_platform_and_wait_for_response_with_private_key( &self.sdk, - asset_lock, + primary.clone(), &voucher_priv.0, identity_signer, - settings, + s, ) - .await - .map_err(PlatformWalletError::Sdk)?; + }) + .await + { + Ok(identity) => identity, + Err(e) if is_instant_lock_proof_invalid(&e) => { + let Some(chain_proof) = chain_fallback else { + // The islock was rejected but the funding tx is not yet + // chain-locked, so no ChainLock proof can be built. Surface a + // clear retry signal rather than the raw consensus error. + 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_with_cl_height_retry(settings, |s| { + placeholder.put_to_platform_and_wait_for_response_with_private_key( + &self.sdk, + chain_proof.clone(), + &voucher_priv.0, + identity_signer, + s, + ) + }) + .await + .map_err(PlatformWalletError::Sdk)? + } + Err(e) => return Err(PlatformWalletError::Sdk(e)), + }; // Best-effort local bookkeeping — Platform has already accepted the // registration, so a local failure must NOT propagate (mirrors @@ -582,10 +647,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 +731,41 @@ where Ok(None) } +/// 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 +783,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 +814,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 +844,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 +967,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 From 6820b348c22a9d16665a3a55d0f6918227b85f23 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:47:04 -0400 Subject: [PATCH 2/4] docs(platform-wallet): record the test-coverage boundary of the IS->CL resubmission arm The unit tests pin proof assembly (IS primary + optional CL fallback) but not the resubmission arm itself, which needs an injectable submission seam to be unit-testable; note that at the arm so the gap raised in review is visible in the code. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/identity/network/invitation.rs | 4 ++++ 1 file changed, 4 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 a9b02621e4..010e5c2a41 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -544,6 +544,10 @@ impl IdentityWallet { .await { Ok(identity) => identity, + // Coverage note (#4240 review): the unit tests pin proof assembly + // (IS primary + optional CL fallback) but not this resubmission arm + // itself — exercising it needs an injectable submission seam, so + // until one exists regressions here surface only in live claims. Err(e) if is_instant_lock_proof_invalid(&e) => { let Some(chain_proof) = chain_fallback else { // The islock was rejected but the funding tx is not yet From 232794f204ef00ad046c0c4fe4f9d4e0223670aa Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:22:22 -0400 Subject: [PATCH 3/4] fix: adapt the reconstructed-proof consumers to #4332's prospective-id path The salvaged fallback changes reconstruct/assemble to return a ReconstructedProof carrying the optional ChainLock fallback; #4332's prospective-identity-id caller and its two tests derive the id from the PRIMARY proof (both proofs cover the same credit output, so the id is identical either way). 619 tests green. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/network/invitation.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 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 010e5c2a41..2fecb01ce5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -424,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}" )) @@ -1069,7 +1073,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(); @@ -1116,6 +1120,7 @@ mod tests { let id = assemble_asset_lock_proof(tx, true, 100, &inv) .unwrap() + .primary .create_identifier() .unwrap(); From df7c0a022f142495f77b6fdf312416cd26dcd2f9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 11 Aug 2026 15:56:17 +0700 Subject: [PATCH 4/4] test(platform-wallet): cover the stale-islock resubmission arm via an injectable seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim's two-attempt submission flow (primary proof, then the ChainLock fallback on Platform's stale-islock rejection) was pinned only by proof-assembly tests, so a regression in the orchestration itself — submitting the fallback for an unrelated error, skipping it for a stale islock, or losing the AssetLockNotChainLocked retry signal — would surface only in live claims. Extract the orchestration into submit_claim_with_stale_islock_fallback, generic over the submit future (mirroring fetch_funding_tx_with_retry's seam), and script it directly: - a successful primary never touches the fallback - a stale-islock rejection resubmits the ChainLock fallback - a stale-islock rejection with no fallback surfaces AssetLockNotChainLocked - an unrelated SDK error propagates without a second submission - the recovery runs exactly once (a rejected fallback is not re-recovered) claim_invitation now routes through the seam unchanged in behavior; the coverage-boundary note recorded on the arm is superseded and removed. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/network/invitation.rs | 286 ++++++++++++++---- 1 file changed, 232 insertions(+), 54 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 2fecb01ce5..f9fde3850c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -523,66 +523,33 @@ impl IdentityWallet { revision: 0, }); - // Submit the primary proof, 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). - // - // If Platform rejects the primary IS proof with - // `InvalidInstantAssetLockProofSignatureError` (the islock is stale — its - // signing quorum rotated out, or it is no longer "recent"; Platform's own - // message asks us to "try chain asset lock proof instead"), resubmit the - // ChainLock proof over the SAME credit output. This mirrors - // `register_identity_with_funding`'s IS→CL fallback, except the CL proof is - // 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 identity = match submit_with_cl_height_retry(settings, |s| { - placeholder.put_to_platform_and_wait_for_response_with_private_key( - &self.sdk, - primary.clone(), - &voucher_priv.0, - identity_signer, - s, - ) - }) - .await - { - Ok(identity) => identity, - // Coverage note (#4240 review): the unit tests pin proof assembly - // (IS primary + optional CL fallback) but not this resubmission arm - // itself — exercising it needs an injectable submission seam, so - // until one exists regressions here surface only in live claims. - Err(e) if is_instant_lock_proof_invalid(&e) => { - let Some(chain_proof) = chain_fallback else { - // The islock was rejected but the funding tx is not yet - // chain-locked, so no ChainLock proof can be built. Surface a - // clear retry signal rather than the raw consensus error. - 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_with_cl_height_retry(settings, |s| { + // 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( - &self.sdk, - chain_proof.clone(), + sdk, + proof.clone(), &voucher_priv.0, identity_signer, s, ) }) - .await - .map_err(PlatformWalletError::Sdk)? - } - Err(e) => return Err(PlatformWalletError::Sdk(e)), - }; + }) + .await?; // Best-effort local bookkeeping — Platform has already accepted the // registration, so a local failure must NOT propagate (mirrors @@ -739,6 +706,53 @@ 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 @@ -1294,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 {