-
Notifications
You must be signed in to change notification settings - Fork 56
fix(platform-wallet): fail a double-spending asset lock with a typed terminal error #4356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.2-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -265,6 +265,8 @@ pub enum PlatformWalletFFIResultCode { | |
| // 38 ErrorDocumentPriceChanged DPNS username marketplace | ||
| // 39 ErrorInsufficientIdentityCredits DPNS username marketplace | ||
| // 40 ErrorContestedNameNotTradable DPNS username marketplace | ||
| // 41 ErrorShieldedInsufficientBalance Platform→Shielded capacity preflight | ||
| // 42 ErrorAssetLockInputConflict asset-lock double-spend detection | ||
| // | ||
| // 38/39/40 carry a STABLE JSON detail object in the result `message` | ||
| // instead of the typed `Display` rendering — see each variant's doc for | ||
|
|
@@ -369,6 +371,39 @@ pub enum PlatformWalletFFIResultCode { | |
| /// shortfall, not a shielded-note shortfall. | ||
| ErrorShieldedInsufficientBalance = 41, | ||
|
|
||
| /// Maps `PlatformWalletError::AssetLockInputConflict`. The tracked | ||
| /// asset-lock transaction spends an outpoint that a different, | ||
| /// already-confirmed transaction of the same wallet spent first — the | ||
| /// classic restored-wallet failure, where a rescan resurrects a UTXO | ||
| /// the wallet's own earlier asset lock had long since consumed. Such a | ||
| /// transaction is a double spend: peers drop it at the mempool | ||
| /// boundary and send nothing back (no BIP61 `reject`), so it can never | ||
| /// be mined or IS-locked and the resume's proof wait would hang | ||
| /// indefinitely. | ||
| /// | ||
| /// TERMINAL, and the only code here that authorises a host to discard | ||
| /// a tracked asset lock: this resume broadcast nothing and no retry of | ||
| /// this outpoint can ever succeed while the confirmed spender stands. | ||
| /// The remedy is to drop the lock and build a new one from | ||
| /// currently-unspent inputs — a fund-safe action either way, because | ||
| /// the conflicting spender is necessarily this wallet's own | ||
| /// transaction (only this wallet can sign its outpoints): the value | ||
| /// lives in the sibling, and even a freak reorg that removed the | ||
| /// sibling would simply return the inputs to the spendable set. | ||
| /// Contrast `ErrorTransactionBroadcastUnconfirmed`, where the tx may | ||
| /// well be alive and discarding it would strand real funds. | ||
|
Comment on lines
+384
to
+394
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Correct the Broadcast-state description Code 41 can be returned for both source: ['coderabbit']
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved in this update — Correct the Broadcast-state description no longer present. Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread. |
||
| /// | ||
| /// Raised only on a positive detection; its ABSENCE is not a liveness | ||
| /// signal. The wallet-side scan reads confirmed records still held in | ||
| /// memory, and under the default `keep-finalized-transactions = OFF` | ||
| /// build those are pruned once chainlocked, so an old conflict can go | ||
| /// unseen and surface as the usual finality timeout instead. | ||
| /// | ||
| /// Message: the typed `Display` rendering, which names the asset-lock | ||
| /// outpoint, the conflicting input, the confirmed spender's txid, and | ||
| /// the spender's finality (chainlocked or merely in a block). | ||
| ErrorAssetLockInputConflict = 42, | ||
|
|
||
| /// The named thing does not exist. | ||
| /// | ||
| /// Originally (and still mostly) the code for every `Option` returned as an | ||
|
|
@@ -621,6 +656,13 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult { | |
| PlatformWalletError::AssetLockFundingMismatch { .. } => { | ||
| PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch | ||
| } | ||
| // Terminal double spend. Distinct from every other asset-lock | ||
| // code because it is the one that tells a host the lock is dead | ||
| // rather than pending: without it this reached `ErrorUnknown`, | ||
| // which no host may act on destructively. | ||
| PlatformWalletError::AssetLockInputConflict { .. } => { | ||
| PlatformWalletFFIResultCode::ErrorAssetLockInputConflict | ||
| } | ||
| // A quiesce/drain barrier that did not complete within budget | ||
| // (clear/reset paths). The host must fail closed: keep its | ||
| // callback context alive and skip any paired persistence wipe. | ||
|
|
@@ -1584,6 +1626,49 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| /// The terminal double-spend verdict is the one code a host may act on | ||
| /// destructively (discard the tracked lock), so both halves of the | ||
| /// contract are pinned: the number the Swift/Kotlin mirrors decode, and | ||
| /// the conversion that keeps it from flattening to `ErrorUnknown`. The | ||
| /// message must carry the typed `Display` — including the spender's | ||
| /// finality — since that is the only detail channel the frozen | ||
| /// `{ code, message }` ABI has. | ||
| #[test] | ||
| fn asset_lock_input_conflict_code_is_pinned_at_42() { | ||
| use dashcore::OutPoint; | ||
|
|
||
| assert_eq!( | ||
| PlatformWalletFFIResultCode::ErrorAssetLockInputConflict as i32, | ||
| 42 | ||
| ); | ||
|
|
||
| let out_point = OutPoint::null(); | ||
| let result: PlatformWalletFFIResult = PlatformWalletError::AssetLockInputConflict { | ||
| out_point, | ||
| input: OutPoint { | ||
| txid: out_point.txid, | ||
| vout: 3, | ||
| }, | ||
| spent_by: out_point.txid, | ||
| height: Some(1_234), | ||
| spender_chain_locked: true, | ||
| } | ||
| .into(); | ||
| assert_eq!( | ||
| result.code, | ||
| PlatformWalletFFIResultCode::ErrorAssetLockInputConflict | ||
| ); | ||
| let message = message_of(&result); | ||
| assert!( | ||
| message.contains("can never confirm"), | ||
| "the typed Display must survive the conversion: {message}" | ||
| ); | ||
| assert!( | ||
| message.contains("chainlocked: true"), | ||
| "the spender's finality must reach the host: {message}" | ||
| ); | ||
| } | ||
|
|
||
| /// `MessageSigningFailed` is intentionally unmapped: its causes are | ||
| /// internal invariant breaks, which should read as a bug rather than as a | ||
| /// key-repair prompt, so it falls through to ErrorUnknown carrying the | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.