Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2687ae0
fix(platform-wallet): fail a double-spending asset lock with a typed …
QuantumExplorer Aug 10, 2026
7d9be71
fix(platform-wallet): report spender finality and carry the conflict …
QuantumExplorer Aug 11, 2026
9c955dc
fix(platform-wallet): give the conflict screen a source that survives…
romchornyi Aug 19, 2026
aaed39d
fix(platform-wallet): reserve the terminal conflict verdict for a cha…
QuantumExplorer Aug 19, 2026
679e860
fix(swift-sdk): make every isSpent writer monotonic, and tidy the rev…
QuantumExplorer Aug 19, 2026
b43dcb1
Merge remote-tracking branch 'origin/v4.2-dev' into pr-4356-work
QuantumExplorer Aug 19, 2026
ea40dd6
fix(platform-wallet-ffi): initialize the test entry as a struct literal
QuantumExplorer Aug 19, 2026
6731994
fix(platform-wallet): restore the spender records themselves and reti…
QuantumExplorer Aug 19, 2026
15a1cb6
fix(swift-sdk): publish the catch-up verdict the moment its task returns
QuantumExplorer Aug 19, 2026
a896e1f
fix(swift-sdk): hand the catch-up task a MainActor publish closure, n…
QuantumExplorer Aug 19, 2026
db2d890
fix(platform-wallet): survive the promotion-eviction and reconcile ev…
QuantumExplorer Aug 19, 2026
8b65c41
fix(platform-wallet): restored evidence stays provisional, seeds earl…
QuantumExplorer Aug 19, 2026
086fdd5
Merge origin/v4.2-dev: renumber the conflict verdicts to 47/48
QuantumExplorer Aug 28, 2026
0fc0845
fix(platform-wallet): the conflict screen emits only the provisional …
QuantumExplorer Aug 28, 2026
7cdb0b5
fix(platform-wallet): a conflict sighting bounds the resume, it never…
shumkov Aug 29, 2026
4c24207
Merge remote-tracking branch 'origin/v4.2-dev' into claude/nifty-shte…
shumkov Aug 30, 2026
9d5c0e2
fix(platform-wallet): initialize observed_input_conflicts in the fold…
shumkov Aug 30, 2026
9de7db7
Merge remote-tracking branch 'origin/v4.2-dev' into claude/nifty-shte…
shumkov Aug 30, 2026
717510c
fix(platform-wallet): bound every resumed asset lock
shumkov Aug 31, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,65 @@ sealed class DashSdkError(
class AssetLockFundingMismatch(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorAssetLockInputConflict` (native code 47). RESERVED — the
* native side has no code path that produces it today, so this class
* is never instantiated from a real result.
*
* It is the TERMINAL form of the double-spend verdict: the tracked
* asset-lock transaction spends an outpoint a different,
* already-confirmed transaction of the same wallet spent first, AND
* that spender's block is proven to be on the finalized chain. The
* proof is what is missing — chainlock contexts and the wallet's
* applied chainlock height are height-based promotion artifacts, not
* evidence of finalized ancestry — so every detection arrives as
* [AssetLockInputContested] (48) instead, chainlocked-looking
* spenders included.
*
* Kept (with its mapping arm) so the reserved code stays wired and
* hosts branching on it keep compiling. If it ever ships it keeps its
* meaning: NOT retryable, and the one code that lets a host discard
* the asset lock and rebuild it from currently-unspent inputs. Read
* nothing into its absence. The Android analog of Swift's
* `PlatformWalletError.assetLockInputConflict`.
*/
class AssetLockInputConflict(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorAssetLockInputContested` (native code 48). A confirmed
* transaction of this wallet already spent one of the tracked lock's
* inputs — typically a restored wallet whose rescan resurrected a
* UTXO one of its own earlier asset locks had already consumed. Peers
* drop such a double spend without replying, so the lock cannot
* confirm while that spender stands and an unbounded proof wait would
* hang. The resume still runs: the sighting bounds that wait instead
* of replacing it, so the lock was (re-)broadcast and waited on (a
* `Broadcast`-status lock was also sent on an earlier call), and this
* is what the bounded wait expired with.
*
* The ONLY double-spend verdict the native side emits, and it is
* PROVISIONAL. NO discard licence: keep the tracked lock and retry
* later (next launch, or after the next chainlock) — but note a
* chainlock does NOT upgrade this to code 47 today; what a retry can
* resolve is a reorg dropping the sibling. Repetition does not
* license a discard either: a conflict that survives session after
* session still proves nothing about finalized ancestry — the
* sighting can be a block record restored from a previous session
* whose block was reorganized out while the host was offline. Only
* code 47, or an independent finalized-ancestry proof, authorizes
* dropping the tracked state. Keeping the lock costs nothing: the
* confirmed spender is this wallet's own transaction, so the value
* lives on in it either way. Its absence is not proof of liveness —
* the native scan cannot see conflicts whose spender was already
* pruned. The Android analog of Swift's
* `PlatformWalletError.assetLockInputContested`.
*/
class AssetLockInputContested(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val isRetryable: Boolean get() = true
}

/**
* `ErrorAssetLockInsufficientFunds` (native code 29). Asset-lock coin
* selection came up short over the build's *permitted funding set*.
Expand Down Expand Up @@ -582,6 +641,13 @@ sealed class DashSdkError(
}.getOrNull()
} ?: PlatformWallet.Generic(code, message, cause)
41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause)
// ErrorAssetLockInputConflict — RESERVED, no native emitter yet;
// the arm stays so the code would not fall through to Generic if
// a finalized-ancestry proof ever starts raising it.
47 -> PlatformWallet.AssetLockInputConflict(message, cause)
// ErrorAssetLockInputContested — the double-spend verdict the
// native side actually emits.
48 -> PlatformWallet.AssetLockInputContested(message, cause)
// ErrorSigningKeyUnavailable — the STRUCTURED signer
// discriminator (dashpay/platform#4060 finding 7): the typed
// completion code rides the whole Rust round-trip, no message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,65 @@ class DashSdkErrorTest {
)
}

@Test
fun assetLockInputConflictCode47MapsTyped() {
// TERMINAL and RESERVED: no native path emits it today (that needs a
// finalized-ancestry proof the wallet cannot make), so this drives
// the mapping with a hand-built exception. The arm must stay wired —
// if a future emitter ships, the code must not fall through to
// Generic and leave the host unable to classify a dead lock.
val message =
"Asset lock a:0 can never confirm: it spends b:1, which was already spent by " +
"confirmed transaction c (block height Some(1234), chainlocked: true) — " +
"the lock is a double spend and no peer will relay it"
val mapped = DashSdkError.fromNative(
DashSDKException(
DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 47,
message,
),
)

assertTrue(
"code 47 must not fall through to Generic",
mapped is DashSdkError.PlatformWallet.AssetLockInputConflict,
)
assertEquals(message, mapped.message)
assertFalse(
"AssetLockInputConflict is terminal — rebuild from unspent inputs, do not retry",
mapped.isRetryable,
)
}

@Test
fun assetLockInputContestedCode48MapsTypedAndRetryable() {
// PROVISIONAL, and the ONLY double-spend verdict the native side
// emits: the wallet cannot prove the confirmed spender's block is on
// the finalized chain, so the host keeps the tracked lock and retries
// later. It must never be treated as the reserved 47's discard
// licence, and it must never fall through to Generic.
val message =
"Asset lock a:0 cannot currently confirm: it spends b:1, which confirmed " +
"transaction c (block height Some(1234)) has taken — the verdict is " +
"provisional (the wallet cannot prove the spender's finality); keep " +
"the lock and retry later"
val mapped = DashSdkError.fromNative(
DashSDKException(
DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 48,
message,
),
)

assertTrue(
"code 48 must not fall through to Generic",
mapped is DashSdkError.PlatformWallet.AssetLockInputContested,
)
assertEquals(message, mapped.message)
assertTrue(
"AssetLockInputContested is provisional — keep the lock and retry later",
mapped.isRetryable,
)
}

@Test
fun signingKeyUnavailableCode31MapsTyped() {
// The STRUCTURED discriminator (dashpay/platform#4060 finding 7):
Expand Down
12 changes: 7 additions & 5 deletions packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,18 @@ These are shipped ABI. Do not renumber.
| 98 | `NotFound` | Sentinel — `Option` returned as an error |
| 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors |

**Next allocatable integer: 48** — 27–47 are all claimed (27, 29, 31, 34–42
and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47
proposed by active #4356 (renumbered from 42 — see its row below); 28, 30,
**Next allocatable integer: 49** — 27–48 are all claimed (27, 29, 31, 34–42
and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and
48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their
rows below); 28, 30,
32 and 33 reserved). **28, 30,
32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the
reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners
(#4310, #4311) closed without merging. All four are deliberately left
unclaimed rather than back-filled, so no number is reused within a single
review cycle. Rule 1's "do not reuse a gap unless this file marks it free"
applies — this file does **not** mark any of them free, so the frontier is
the only allocation source and a new code takes 48. (42 is a cautionary tale:
the only allocation source and a new code takes 49. (42 is a cautionary tale:
merged #4451 minted it while active #4356 held the claim — merged ABI wins,
the open PR renumbers. 46's near-miss went the other way: caught in review,
renumbered before merge.)
Expand All @@ -149,7 +150,8 @@ Fork-era numbers remain in the collision history, which is immutable record.
| Code | Name | Owning PR | Status |
| ---: | --- | --- | --- |
| 28 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source |
| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — **47 is reserved for this active PR, but the three-layer renumber is still PENDING.** Merged #4451 took 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22, and merged ABI wins. At the cited #4356 head `7d9be71a08`, Rust still defines and tests `ErrorAssetLockInputConflict = 42`, Swift still declares `errorAssetLockInputConflict = 42`, and Kotlin still maps and tests native 42 — #4356 must move all three layers and their tests together to 47 before it can merge. Rule 1 makes 47 unavailable to any other contributor while #4356 is active |
| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — three-layer renumber from 42 **complete** on the branch (Rust value + pin test, Swift raw case, Kotlin arm + test all at 47). Merged #4451 had taken 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22; merged ABI won and #4356 moved. **Reserved-with-no-emitter**: the wallet currently constructs only the provisional 48 — 47 is the terminal discard-licensing verdict, held for a future finalized-ancestry proof the SPV layer does not yet expose. The number is claimed ABI either way; Rule 1 makes 47 unavailable to any other contributor while #4356 is active |
| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: the sighting BOUNDS the proof wait rather than replacing it, so the lock is still (re-)broadcast and waited on, and 48 is emitted only when that bounded wait expires with the conflict still standing; carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch |
| 30 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source |
| 32 | *(reserved — lapsed)* | — | Owner #4310 (successor of fork-era #4247) closed without merging; RESERVED, not reissuable |
| 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable |
Expand Down
Loading
Loading