Repository navigation
Wallet drain completes on an unauthenticated "already consumed" report; add a proof-backed asset-lock outpoint query #5580
Description
Problem
A wallet drain (ShieldFromAssetLock, Type 18, used by iOS Core → Shielded Max, dashpay/dashwallet-ios#1205) is recorded as completed on an unauthenticated "asset lock already consumed" report.
settle_asset_lock_submit_result in packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs (L87–L108 at 84851397d7) calls complete_wallet_drain as soon as is_asset_lock_already_consumed(error, out_point) matches. That error is a plain ConsensusError carried in gRPC metadata (dash-serialized-consensus-error-bin, deserialized with deserialize_from_bytes_untrusted in packages/rs-sdk/src/error.rs) or returned from the wait-stream error branch in packages/rs-sdk/src/platform/transition/broadcast.rs. It is produced by a single node's CheckTx, before any block, and carries no quorum signature. The node already knows the outpoint it was sent, so a match on it proves nothing.
The generic asset-lock path handles the same report conservatively: reconcile_asset_lock_submit_result (packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs) keeps the lock as consumption-unknown and surfaces AssetLockAlreadyConsumed (FFI code 24). Only the drain path promotes it to a terminal DrainCompleted.
Tracking issue on the iOS side: dashpay/dashwallet-ios#1230. Originally raised by @thepastaclaw in dashpay/dashwallet-ios#1205 (comment).
Impact
A malicious or faulty DAPI node can withhold the Type 18 and answer "already consumed". The SDK durably writes DrainCompleted and drops the proof; the app clears its recovery marker and shows "Transfer complete"; the Orchard note never arrives; later resumes are refused because the lock is terminal. The attacker cannot take the funds (lock key and recipient stay the user's), but the user gets a false success and the full drained balance sits in an unclaimed asset lock that needs manual recovery.
For a drain to the wallet's own Orchard address the app can at least see the funds via its own scan. For a drain to an external recipient (a planned Send-screen Max) there is no local check at all, so this report would be the only source of truth.
Why "stop trusting the error" alone is not enough
If the SDK simply keeps the drain pending on that report, a lock that was genuinely consumed by our own earlier Type 18 (response lost after the block landed) can never be closed: every resume re-submits and gets the same error forever. That is the behaviour the generic path already has ("Completion unknown"). We need a way to prove the outpoint's state.
Attaching a proof to the error itself is possible in principle but worse: ConsensusError is a versioned DPP type, the proof would have to be produced inside CheckTx (mempool hot path), the wait-stream branch would need the same, and the proof would only be available at submit time, not on a later resume.
Proposal: a proof-backed asset-lock outpoint query
Drive already stores spent outpoints and has Drive::fetch_asset_lock_outpoint_info(outpoint) -> StoredAssetLockInfo (packages/rs-drive/src/drive/asset_lock/fetch_asset_lock_outpoint_info/). There is no prove_ sibling and no DAPI query for it (platform.proto has no asset-lock RPC).
Add the standard query stack, following e.g. getDataContractsLatestVersions (#4739):
rs-drive:prove_asset_lock_outpoint_info+ method version entry.rs-drive-proof-verifier:verify_asset_lock_outpoint_info.rs-drive-abci: query handler +drive_abci_query_versionsentries.dapi-grpc:getAssetLockOutpointInfo(or an outpoint list ongetProofs), regenerated clients.rs-dapi/dapi: pass-through.rs-sdk/rs-dapi-client:Fetchimpl returning the info (not spent / partially consumed with remaining credits / fully consumed) with verified proof.rs-platform-wallet: insettle_asset_lock_submit_result, on an already-consumed report query the outpoint with proof; writeDrainCompletedonly when the verified state says the lock is fully consumed; otherwise keep the drain pending and resumable by operation UUID + outpoint. Tests for both outcomes and for the no-proof case.- The same query lets the generic path resolve "Completion unknown" instead of leaving it to the user.
Open questions:
- New RPC vs. extending
getProofswith outpoints. - Whether the response should include the consuming transition / identity, or just status + remaining credits.
- Should
complete_wallet_drainalso accept a proof-backed Type 18 result fetched by hash on resume, so the lost-response case closes without the new query?
Once this lands, iOS bumps .platform-ref and re-checks the Max form, runner and history recovery sheet (dashpay/dashwallet-ios#1230, item 2).
Activity
thepastaclaw commentedon Oct 26, 2026
@llbartekll on it — I've queued this as job
cj-20261026-095205-7468and will reply here or link a PR when there's something to look at.thepastaclaw commentedon Oct 26, 2026
Implemented the proof-backed
getAssetLockOutpointInfoquery across Drive, proof verification, ABCI, DAPI, generated clients, and the Rust SDK. The existing wallet reconciliation remains nonterminal for unauthenticatedalready consumedreports. PR: #5581feat(wallet): Max to an external shielded address uses the SDK wallet…