Skip to content
Closed
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
107 changes: 107 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,50 @@ pub fn is_instant_lock_proof_invalid(error: &dash_sdk::Error) -> bool {
)
}

/// Extract the outpoint from Platform's rejection of an asset lock whose
/// credit output has already been spent by an earlier transition
/// (`IdentityAssetLockTransactionOutPointAlreadyConsumedError`, "Asset lock
/// transaction {txid} output {n} already completely used").
///
/// The rejection is **deterministic and terminal**: the credits it would have
/// bought already landed, so every retry receives the same answer. Callers
/// treat it as the success-shaped outcome it really is — mark the lock
/// [`Consumed`](crate::wallet::asset_lock::tracked::AssetLockStatus::Consumed)
/// and stop resuming it — rather than as a failure to retry.
///
/// Returns the outpoint Platform named. The verdict is an unauthenticated
/// error relayed by a single DAPI node — callers must bind it to the
/// outpoint they actually submitted before settling anything
/// ([`AssetLockManager::settle_reported_consumed`](crate::wallet::asset_lock::manager::AssetLockManager::settle_reported_consumed)
/// does), or a fabricated verdict could tombstone an unrelated lock.
///
/// Companion to [`is_instant_lock_proof_invalid`]: both classify a Platform
/// rejection of an asset-lock proof, but that one is retryable via a CL
/// upgrade while this one can never succeed.
pub fn asset_lock_already_consumed_out_point(
error: &dash_sdk::Error,
) -> Option<dashcore::OutPoint> {
use dpp::consensus::basic::BasicError;
use dpp::consensus::ConsensusError;

let consensus_error = match error {
dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => {
broadcast_err.cause.as_ref()
}
dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()),
_ => None,
};
match consensus_error {
Some(ConsensusError::BasicError(
BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError(e),
)) => Some(dashcore::OutPoint {
txid: e.transaction_id(),
vout: e.output_index() as u32,
}),
_ => None,
}
}
Comment on lines +537 to +559

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.

🟡 Suggestion: Add regression coverage for consumed-error classification and settlement

The two new tests cover only the Built rebroadcast behavior. There is no test proving that asset_lock_already_consumed_out_point extracts the correct outpoint from both supported SDK wrappers, rejects unrelated errors, or that the resulting settlement persists Consumed and removes the lock from the resumable set. The adjacent address-nonce classifier already demonstrates the expected wrapper-by-wrapper test pattern. Add equivalent classifier tests and an end-to-end wallet-state assertion for the consumed settlement path.

source: ['codex']


/// Check whether a platform-wallet error represents a *Core-side*
/// InstantSend lock timeout (the asset-lock manager waited the full
/// timeout for an IS-lock proof and never observed one).
Expand Down Expand Up @@ -906,4 +950,67 @@ mod address_nonce_tests {
assert_eq!(got.provided_nonce(), 9);
assert_eq!(got.expected_nonce(), 10);
}

/// Platform's "already completely used" rejection naming `out_point`,
/// as the raw consensus error both wire shapes carry.
fn already_consumed_cause(out_point: dashcore::OutPoint) -> dpp::consensus::ConsensusError {
use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError;
use dpp::consensus::basic::BasicError;
use dpp::consensus::ConsensusError;
ConsensusError::BasicError(
BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError(
IdentityAssetLockTransactionOutPointAlreadyConsumedError::new(
out_point.txid,
out_point.vout as usize,
),
),
)
}

fn already_consumed_test_out_point() -> dashcore::OutPoint {
use dashcore::hashes::Hash as _;
dashcore::OutPoint {
txid: dashcore::Txid::from_byte_array([7u8; 32]),
vout: 3,
}
}

#[test]
fn extracts_already_consumed_out_point_from_both_shapes() {
let out_point = already_consumed_test_out_point();
let protocol = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(
already_consumed_cause(out_point),
)));
// The gRPC broadcast/wait rejection shape; the classifier reads only
// the cause, so the code and message are inert.
let broadcast =
dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError {
code: 10504,
message: "asset lock already completely used".to_string(),
cause: Some(already_consumed_cause(out_point)),
});
for err in [protocol, broadcast] {
assert_eq!(asset_lock_already_consumed_out_point(&err), Some(out_point));
}
}

#[test]
fn already_consumed_classifier_ignores_unrelated_and_causeless_errors() {
// A plainly unrelated SDK error.
assert!(
asset_lock_already_consumed_out_point(&dash_sdk::Error::Generic("boom".to_string()))
.is_none()
);
// A different consensus rejection (address-nonce) must not read as a
// consumed verdict.
assert!(asset_lock_already_consumed_out_point(&protocol_shape(1, 2)).is_none());
// The DAPI wait-timeout shape: no consensus cause, no verdict.
let causeless =
dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError {
code: 0,
message: "timeout".to_string(),
cause: None,
});
assert!(asset_lock_already_consumed_out_point(&causeless).is_none());
}
}
Loading
Loading