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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions