Repository navigation
rs-platform-wallet-storage: AssetLockProof blobs can be written but never read back (bincode/serde deserialize_any incompatibility) #4133
Description
Summary
An asset_locks row's lifecycle_blob can hold a serialized AssetLockProof that encodes successfully but can never be decoded back — permanently and reproducibly. Every subsequent SqlitePersister::load_from_persistor() (wallet rehydration) call for a persister containing such a row fails, which in dash-evo-tool manifests as "chain sync never starts" on every app relaunch, blocking every Testnet-dependent feature.
Confirmed at platform commit 93b967f9c7ab0164b47fe825d2bae58b3974625c (current dash-evo-tool v1.0-dev pin). Not data corruption, not disk/lock contention — a genuine wire-format incompatibility, root-caused via a standalone repro binary against the pinned crate, not inferred from a user-facing message.
Root cause
AssetLockProof (packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs:37-41) is deliberately an internally-tagged serde enum:
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Encode, Decode)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum AssetLockProof {
Instant(#[bincode(with_serde)] InstantAssetLockProof),
Chain(#[bincode(with_serde)] ChainAssetLockProof),
}The type also derives bincode's own native Encode/Decode — but the variant fields are annotated #[bincode(with_serde)], meaning bincode's derive delegates to serde::Serialize/Deserialize for the inner content, not its own native codec.
rs-platform-wallet-storage's shared blob codec (packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs:85-117) is a one-size-fits-all wrapper used by every _blob column via a sealed PersistableBlob: Serialize trait:
pub fn encode<T: PersistableBlob>(value: &T) -> Result<Vec<u8>, WalletStorageError> {
Ok(bincode::serde::encode_to_vec(value, bounded_config())?)
}
pub fn decode<T: DeserializeOwned>(blob: &[u8]) -> Result<T, WalletStorageError> {
// bincode::serde::decode_from_slice(...)
}bincode::serde's bridge deserializer does not implement deserialize_any (it can't — bincode has no self-describing length/type prefix to support arbitrary lookahead). An internally-tagged enum's Deserialize impl requires exactly that (buffer the whole map/struct to find the "type" discriminant before knowing which variant to build). Result: bincode::serde::encode_to_vec succeeds (serialization never needs lookahead), but bincode::serde::decode_from_slice fails deterministically with Serde(AnyNotSupported), surfaced as WalletStorageError::BincodeDecode.
A row containing an AssetLockProof can be written, but can never be read back, by design of the current codec pairing.
Full failure chain (dash-evo-tool side, for context)
AppContext::ensure_wallet_backend_and_start_spv
-> AppContext::ensure_wallet_backend
-> WalletBackend::new
-> SqlitePersister::open() succeeds
-> register_persisted_wallets -> load_from_persistor_seedless
-> PlatformWalletManager::load_from_persistor() <-- fails here, every time
-> PlatformWalletError::PersisterLoad(PersistenceError)
-> wraps WalletStorageError::BincodeDecode { source: Serde(AnyNotSupported) }
Once one such row exists, rehydration fails on every subsequent process launch — this is not intermittent.
Secondary masking effect (worth fixing too, separately)
PlatformWalletManager::new spawns a wallet-event adapter that retains an Arc<SqlitePersister> before the fallible load_from_persistor call resolves. When that load fails, the failed-construction path doesn't tear the adapter down, so the persister stays registered as "open" for the rest of that process. Any same-process retry then hits WalletStorageError::AlreadyOpen instead of the real decode error — a confusing secondary symptom that looks unrelated to the actual cause. A fresh process restart clears the guard and goes straight back to the real BincodeDecode failure.
Reproduction
A byte-identical platform-wallet.sqlite containing exactly one such row (an active testnet asset lock, wallet_id=38A9219B..., outpoint=2012A6E6..., status=is_locked, amount_duffs=2000000) is preserved and hashed; happy to share privately if useful — not attaching here since it's a real (if worthless, testnet) wallet artifact.
Minimal repro without the fixture: construct any AssetLockEntry whose lifecycle_blob embeds an AssetLockProof, persist it through rs-platform-wallet-storage's normal write path, then attempt to load it back through PlatformWalletManager::load_from_persistor() (or directly through blob::decode::<AssetLockEntry>). Decode fails with BincodeDecode { source: Serde(AnyNotSupported) } on every attempt.
Suggested fix direction (not prescriptive — flagging for whoever picks this up)
AssetLockProof already derives bincode's native Encode/Decode at the outer level (independent of the #[bincode(with_serde)]-annotated inner fields per the type's own doc comment: "Bincode Encode/Decode derives are independent of serde"). A fix likely needs one of:
- A second blob-codec path for types that implement bincode's native
Encode/Decodedirectly (bypassing thebincode::serdebridge entirely for those types), used forAssetLockEntry/AssetLockProofspecifically. - Or a different self-describing serialization format for this one blob column that supports internally-tagged enums (note:
blob.rs's own doc comment states "Schema evolution is gated by the refinery migration version — no per-blob revision tag," so introducing a second format needs to account for that).
Either way, please audit every other impl_persistable_blob! call site for the same latent issue — AssetLockProof is very unlikely to be the only internally-tagged (or otherwise deserialize_any-requiring) serde shape reachable through this shared codec, and this bug class will silently recur for any future type with the same shape. A migration/compat-read path should also be considered for any already-written incompatible rows in the wild (this is a live wallet-storage format, not just a test artifact).
Also worth filing (logging/UX, tracked separately in dash-evo-tool)
load_from_persistor's error is logged by the DET consumer with %error (Display) rather than ?error (Debug) at the call site that surfaces this to users, so the real typed variant was invisible in application logs throughout an entire QA campaign — only a dedicated forensic investigation recovered it. Not a platform-repo issue, noting for completeness since it made this bug much harder to diagnose than it should have been.
Environment
platformcommit:93b967f9c7ab0164b47fe825d2bae58b3974625c- Consumer:
dash-evo-tool, branchfix/tx-history(PR fix(rs-drive-abci): config file is broken #892), commit57195d54 - Discovered during a full user-story QA regression campaign against Testnet
🤖 Co-authored by Claudius the Magnificent AI Agent
Activity
docs(qa): PR892 user-story QA campaign — full retest record (175/175 …
fix: rebase SND-003 onto PR893 + QA-campaign fix batches (#894)