Skip to content

rs-platform-wallet-storage: AssetLockProof blobs can be written but never read back (bincode/serde deserialize_any incompatibility) #4133

Description

@lklimek

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:

  1. A second blob-codec path for types that implement bincode's native Encode/Decode directly (bypassing the bincode::serde bridge entirely for those types), used for AssetLockEntry/AssetLockProof specifically.
  2. 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

  • platform commit: 93b967f9c7ab0164b47fe825d2bae58b3974625c
  • Consumer: dash-evo-tool, branch fix/tx-history (PR fix(rs-drive-abci): config file is broken #892), commit 57195d54
  • Discovered during a full user-story QA regression campaign against Testnet

🤖 Co-authored by Claudius the Magnificent AI Agent

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions