Skip to content

fix(platform-wallet): settle already-consumed asset locks and survive an ambiguous resume broadcast - #4337

Closed
HashEngineering wants to merge 2 commits into
dashpay:v4.2-devfrom
HashEngineering:fix/asset-lock-consumed-and-resume
Closed

fix(platform-wallet): settle already-consumed asset locks and survive an ambiguous resume broadcast#4337
HashEngineering wants to merge 2 commits into
dashpay:v4.2-devfrom
HashEngineering:fix/asset-lock-consumed-and-resume

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

Two ways a completed or in-flight asset-lock top-up could never finish, both observed live on an Android testnet wallet (MO-998 / dashpay/dash-wallet#1520):

1. Platform's "already completely used" verdict was dropped. When a transition is rejected with IdentityAssetLockTransactionOutPointAlreadyConsumedError ("Asset lock transaction {txid} output {n} already completely used"), the credits it would have bought already landed — an earlier attempt succeeded and the client never learned. Nothing recorded that locally: consume_asset_lock was only ever called on the success path, so the lock stayed in the resumable set and every recovery pass re-submitted it for the same deterministic rejection, forever. A client that blocks new funding while a lock is unresolved could not buy credits at all until it special-cased the error string (which the Android wallet currently does — this PR is the typed root fix that lets that workaround be deleted).

2. A Built-status resume aborted on an ambiguous re-broadcast. The Built arm of resume_asset_lock propagated every broadcast error, including MaybeSent. For a lock stuck at Built whose transaction WAS broadcast (the app died between the send and the status advance), MaybeSent is the expected answer on every retry — DAPI classifies all failures that way — so the resume failed, the lock stayed Built, and the next pass repeated it. The top-up never completed.

What was done?

  • Both funded flows (register_identity_with_funding, top_up_identity_with_funding) classify the already-consumed rejection from the typed consensus error (asset_lock_already_consumed_out_point, reading the outpoint Platform itself names — never a message match), mark the lock Consumed via the existing consume_asset_lock, and return the same typed AssetLockAlreadyConsumed a resume of a consumed lock already raises — callers need one terminal case.
  • The Built resume arm now distinguishes verdicts: only a definite Rejected stops the resume (nothing is on the network; the lock stays at Built for a later re-send). MaybeSent advances to Broadcast and proceeds to the proof wait — matching what the Broadcast arm already does with the identical signal.

How Has This Been Tested?

  • Two new regression tests assert the status transition, not just the error: an ambiguous re-broadcast must advance Built → Broadcast and reach the proof wait; a definite rejection must keep failing with the lock still resumable at Built.
  • Full suite on the current v4.2-dev tip (rebased over feat(platform-wallet): CoinJoin-drain asset-lock funding for the shielded pool #4327): cargo test -p platform-wallet --lib — 579 passed, 0 failed. cargo clippy -p platform-wallet --lib --tests — clean.
  • The failure pair was reproduced live on an Android testnet wallet before the fix (endless resume_asset_lock retries against the same rejection; a stuck Built lock re-broadcast on every recovery pass).

Breaking Changes

None. Error surfaces gain no new variants; a rejection that previously surfaced as a generic SDK error now surfaces as the existing typed AssetLockAlreadyConsumed.

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved asset-lock recovery when broadcast results are uncertain, allowing the wallet to wait for confirmation instead of failing prematurely.
    • Added consistent handling for already-consumed asset locks during identity registration, top-ups, and funding operations.
    • Automatically synchronizes matching asset-lock state locally and reports a specific terminal error when a lock cannot be reused.
    • Ignores unrelated or mismatched consumption errors and preserves existing behavior for confirmed broadcast failures.

…biguous resume broadcast

Two ways a completed or in-flight asset-lock top-up could never finish,
both observed on Android testnet:

**Platform's "already completely used" verdict was dropped.** When a
transition is rejected with
IdentityAssetLockTransactionOutPointAlreadyConsumedError, the credits it
would have bought already landed — an earlier attempt succeeded and the
client never learned. Nothing recorded that locally: consume_asset_lock
was only ever called on the success path, so the lock stayed in the
resumable set and every recovery pass re-submitted it for the same
deterministic rejection. Clients that block new funding while a lock is
unresolved could not buy credits at all until they special-cased the
error string.

Both funded flows now classify that rejection (typed, via the consensus
error rather than its message), mark the lock Consumed, and return the
same AssetLockAlreadyConsumed a resume of a consumed lock already
raises — so callers need one terminal case, not a string match.

**A Built-status resume aborted on an ambiguous re-broadcast.** The Built
arm propagated every broadcast error, including MaybeSent. For a lock
stuck at Built whose transaction WAS broadcast (the app died between the
send and the status advance), MaybeSent is the expected answer on every
retry — DAPI classifies all failures that way — so the resume failed,
the lock stayed Built, and the next pass repeated it. The top-up never
completed.

Only a definite Rejected now stops the resume; MaybeSent advances to
Broadcast and proceeds to the proof wait, matching what the Broadcast arm
already does with the identical signal and keeping a genuinely
un-broadcast tx resumable at Built.

Tests: two regression tests covering the ambiguous and definite branches
(status transition asserted, not just the error). cargo test -p
platform-wallet --lib asset_lock:: — 26 passed. cargo clippy -p
platform-wallet --lib --tests — no new warnings (the 3 reported are
present on the unmodified base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d2e5209d-0e7d-4c0d-a70b-0df3a8bc9e44

📥 Commits

Reviewing files that changed from the base of the PR and between 3fd9644 and 319c4c9.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/registration.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/rs-platform-wallet/src/wallet/identity/network/registration.rs
  • packages/rs-platform-wallet/src/error.rs

📝 Walkthrough

Walkthrough

The wallet detects already-consumed asset-lock outputs, settles matching tracked locks, and returns typed errors. Registration, top-up, and ChainLock retry paths use this handling. Ambiguous rebroadcasts advance to Broadcast and wait for proof.

Changes

Asset-lock handling

Layer / File(s) Summary
Consumed output detection and settlement
packages/rs-platform-wallet/src/error.rs, packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs, packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
The wallet extracts consumed asset-lock outpoints, validates them against the submitted outpoint, settles matching locks, and returns AssetLockAlreadyConsumed. Tests cover matching, mismatched, unrelated, and causeless errors.
Submission failure settlement
packages/rs-platform-wallet/src/wallet/identity/network/registration.rs, packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs
Registration, top-up, and ChainLock retry paths route consumed-output failures through tracked-lock settlement. Successful submissions remain unchanged.
Ambiguous rebroadcast recovery
packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
resume_asset_lock advances MaybeSent rebroadcasts from Built to Broadcast and waits for proof. Definite failures preserve Built. Tests cover both outcomes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: quantumexplorer, shumkov, lklimek

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes both primary changes: settling already-consumed asset locks and handling ambiguous resume broadcasts.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/asset-lock-consumed-and-resume
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit 319c4c9)
Stage: Codex precheck starting
ETA: complete ~10:45 UTC (median 11m across 30 recent reviews)
Running 9m · Last checked: 2026-08-11 10:40 UTC

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- Around line 266-274: Classify already-consumed errors in both ChainLock retry
failure paths before propagating them: update the registration retry handling at
packages/rs-platform-wallet/src/wallet/identity/network/registration.rs:266-274
and the top-up retry handling at
packages/rs-platform-wallet/src/wallet/identity/network/registration.rs:505-516
to reuse asset_lock_already_consumed_out_point and settle_already_consumed_lock,
while preserving PlatformWalletError::Sdk for other errors.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e067e1cd-9996-4e1d-b40e-cad8d75452b0

📥 Commits

Reviewing files that changed from the base of the PR and between 8f98180 and 3fd9644.

📒 Files selected for processing (3)
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/registration.rs

@thepastaclaw thepastaclaw left a comment

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.

Preliminary review — Codex only

The typed consumed-error extraction and ambiguous-broadcast recovery are directionally correct, but the exact head still has three blocking defects: uncertain pre-send failures can enter an indefinite finality wait, ChainLock fallback submissions bypass consumed-lock settlement, and unauthenticated DAPI errors can permanently tombstone valid locks. The new consumed classification and settlement path also lacks regression coverage.
Source: reviewer backends codex general/security-auditor/rust-quality (gpt-5.6-sol); final verifier backend codex (gpt-5.6-sol). openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:269-284: Preserve a retry path when an ambiguous broadcast never sent the transaction
  `DapiBroadcaster` maps every DAPI failure to `MaybeSent`, including outages where no request delivered the transaction to Core. This branch nevertheless persists `Broadcast` and waits for proof. In the identity funding resolver, the bounded proof timeout becomes `FundingResolution::IsTimeout`, after which registration and top-up call `upgrade_to_chain_lock_proof(..., None)` and wait indefinitely. If the transaction was never sent, no InstantSend or ChainLock proof can ever arrive, and the operation no longer gets another opportunity to rebroadcast. Keep the uncertain acceptance state retryable: after the bounded proof wait expires without positive network evidence, surface `TransactionBroadcastUnconfirmed` or otherwise restore a state that permits another broadcast instead of entering the unbounded ChainLock fallback.

In `packages/rs-platform-wallet/src/wallet/identity/network/registration.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/registration.rs:253-264: Route ChainLock fallback rejections through consumed-lock settlement
  The outer submission error is classified with `asset_lock_already_consumed_out_point`, but the nested ChainLock retry short-circuits through `.map_err(PlatformWalletError::Sdk)?`. The same bypass exists in the top-up path at lines 493-503. If an earlier ambiguous submission commits, or another recovery consumes the outpoint while this flow waits for a ChainLock, the fallback can return `IdentityAssetLockTransactionOutPointAlreadyConsumedError`; the current code leaves the lock resumable and returns a generic SDK error, recreating the terminal retry loop this PR is intended to fix. Normalize both the initial and fallback submission results through the same consumed-error settlement path.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/registration.rs:591-607: Do not permanently settle a lock from an unauthenticated DAPI rejection
  `settle_already_consumed_lock` irreversibly persists `Consumed` solely from a DAPI-provided consensus error. That verdict is not authenticated: the SDK returns wait-stream errors before GroveDB proof and Tenderdash quorum-signature verification (`rs-sdk/src/platform/transition/broadcast.rs:347-397`), while `Protocol(ConsensusError)` can be deserialized directly from unauthenticated gRPC metadata (`rs-sdk/src/error.rs:176-200`). Both error shapes are non-retryable, so one malicious DAPI node can fabricate an already-consumed error, name the submitted outpoint, and remove an actually unspent lock from the wallet's resumable funding set. Binding the reported outpoint to the submitted proof prevents unrelated-lock corruption but does not authenticate the verdict; permanent settlement must wait for quorum-authenticated state evidence, or the lock must remain unsettled and retryable.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:534-556: 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.

Comment on lines 263 to 264
.await
.map_err(PlatformWalletError::Sdk)?

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: Route ChainLock fallback rejections through consumed-lock settlement

The outer submission error is classified with asset_lock_already_consumed_out_point, but the nested ChainLock retry short-circuits through .map_err(PlatformWalletError::Sdk)?. The same bypass exists in the top-up path at lines 493-503. If an earlier ambiguous submission commits, or another recovery consumes the outpoint while this flow waits for a ChainLock, the fallback can return IdentityAssetLockTransactionOutPointAlreadyConsumedError; the current code leaves the lock resumable and returns a generic SDK error, recreating the terminal retry loop this PR is intended to fix. Normalize both the initial and fallback submission results through the same consumed-error settlement path.

source: ['codex', 'coderabbit']

Comment on lines +591 to +607
async fn settle_already_consumed_lock(
&self,
out_point: dashcore::OutPoint,
) -> PlatformWalletError {
tracing::info!(
outpoint = %out_point,
"Platform rejected the asset lock as already completely used — its \
credits landed on an earlier attempt; marking the lock consumed"
);
if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await {
tracing::warn!(
outpoint = %out_point,
error = %e,
"consume_asset_lock failed after Platform's already-used rejection"
);
}
PlatformWalletError::AssetLockAlreadyConsumed(out_point)

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: Do not permanently settle a lock from an unauthenticated DAPI rejection

settle_already_consumed_lock irreversibly persists Consumed solely from a DAPI-provided consensus error. That verdict is not authenticated: the SDK returns wait-stream errors before GroveDB proof and Tenderdash quorum-signature verification (rs-sdk/src/platform/transition/broadcast.rs:347-397), while Protocol(ConsensusError) can be deserialized directly from unauthenticated gRPC metadata (rs-sdk/src/error.rs:176-200). Both error shapes are non-retryable, so one malicious DAPI node can fabricate an already-consumed error, name the submitted outpoint, and remove an actually unspent lock from the wallet's resumable funding set. Binding the reported outpoint to the submitted proof prevents unrelated-lock corruption but does not authenticate the verdict; permanent settlement must wait for quorum-authenticated state evidence, or the lock must remain unsettled and retryable.

source: ['codex']

Comment on lines +534 to +556
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,
}
}

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']

…outpoint and classify every submit arm

Review follow-ups to the already-consumed settlement:

- The settlement moves onto AssetLockManager as settle_reported_consumed
  and now settles ONLY when Platform's verdict names the outpoint this
  submission actually carried. The verdict is an unauthenticated error
  relayed by one DAPI node; binding it stops a fabricated answer from
  tombstoning an unrelated lock (which wiped its proof and stranded the
  burned funds).
- The IS->CL fallback resubmits in registration, top-up, and the
  platform-address funding flow previously mapped rejections straight to
  a generic Sdk error, so a consumed verdict on the second attempt left
  the lock resumable and the endless-retry loop reachable. All six
  rejection arms now route through the shared classification.
- Tests: classifier extraction from both wire shapes plus
  unrelated/causeless passthrough (error.rs), and a manager-level test
  proving an unbound or unrelated rejection leaves the lock untouched
  while the bound verdict tombstones it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member

Closing after splitting this in two, per review of the overlap with #4357 (merged 2026-08-11):

Thanks @HashEngineering — both fixes were real; one of them just got raced by a broader implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants