Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fffc513
refactor(platform-wallet)!: typed PersistenceError with kind + source…
lklimek May 25, 2026
741fc58
refactor(platform-wallet-storage)!: V002 schema — cascade-only identi…
lklimek May 25, 2026
ca5c89c
fix(platform-wallet): refuse silent drop of orphan platform_addresses…
lklimek May 25, 2026
dfb243a
fix(platform-wallet): retry transient + undo on fatal store error in …
lklimek May 25, 2026
31df9ca
feat(platform-wallet): add delete_wallet to PlatformWalletPersistence…
lklimek May 25, 2026
c2c839b
feat(platform-wallet): add commit_writes to PlatformWalletPersistence…
lklimek May 25, 2026
fc921d3
refactor(platform-wallet-storage)!: SQLite-native EXCLUSIVE replaces …
lklimek May 25, 2026
79a2779
fix(platform-wallet-storage): pre-delete backup includes buffered wri…
lklimek May 25, 2026
40714e1
fix(platform-wallet-storage): persistor hardening batch-A (CODE-009/0…
lklimek May 25, 2026
7dff6a4
build(platform-wallet-storage): gate platform-wallet + serde behind s…
lklimek May 25, 2026
81c8f1a
docs(platform-wallet-storage): drop deleted delete-wallet CLI referen…
lklimek May 25, 2026
13e0cd6
test(platform-wallet-storage): surface delete-wallet + COUNT failures…
lklimek May 25, 2026
3e70078
test(platform-wallet-storage): require >=8192 bytes before page-2 cor…
lklimek May 25, 2026
96a3e63
refactor(platform-wallet-storage): single default_auto_backup_dir hel…
lklimek May 25, 2026
2c91955
style(platform-wallet-storage): rustfmt feature_flag_build.rs (CODE-020)
lklimek May 25, 2026
6934404
perf(platform-wallet): cache ClientStartState slices to drop register…
lklimek May 25, 2026
17c2294
refactor(platform-wallet-storage): extract has_schema_history helper …
lklimek May 25, 2026
0a3e843
fix(platform-wallet-storage): extend ConfigInvalid audit + drop match…
lklimek May 25, 2026
9b39f3e
fix(platform-wallet-storage): deprecate --auto-backup-dir "" sentinel…
lklimek May 25, 2026
6602cdb
docs(platform-wallet-ffi): TODO comments at half-wired callback sites…
lklimek May 25, 2026
1c58c72
test(platform-wallet-storage): consumer↔SqlitePersister round-trip in…
lklimek May 25, 2026
4462358
fix(platform-wallet-ffi): require wallet_id in register_identity FFI …
lklimek May 26, 2026
c47ca1e
docs(platform-wallet-storage): INTENTIONAL(SEC-001) on COALESCE ident…
lklimek May 26, 2026
f613bbd
docs(platform-wallet-storage): INTENTIONAL(SEC-002) on token_balances…
lklimek May 26, 2026
0c9cdc9
fix(platform-wallet): wire cached_persisted_shielded in bind_shielded…
lklimek May 26, 2026
a8a0783
fix(platform-wallet-storage): use valid-UTF-8 non-ASCII path in sidec…
lklimek May 26, 2026
d0ed23b
docs(platform-wallet): document default WalletId = orphan convention …
lklimek May 26, 2026
5747a98
docs(platform-wallet): document orphan-bucket convention on flush + c…
lklimek May 26, 2026
16f32f5
Merge branch 'feat/platform-wallet-sqlite-persistor' into fix/3625-th…
lklimek May 26, 2026
3dc48dc
revert(platform-wallet): drop consumer hardening (CODE-001/017/018/00…
lklimek May 26, 2026
7880e69
Revert "docs(platform-wallet-ffi): TODO comments at half-wired callba…
lklimek May 26, 2026
6e65e72
Revert "docs(platform-wallet): document orphan-bucket convention on f…
lklimek May 26, 2026
87ae78b
Revert "docs(platform-wallet): document default WalletId = orphan con…
lklimek May 26, 2026
e28069b
revert(platform-wallet-storage): drop round_trip_consumer.rs (CODE-00…
lklimek May 26, 2026
ff00639
Merge remote-tracking branch 'origin/feat/platform-wallet-sqlite-pers…
lklimek May 27, 2026
4dc7a3a
chore(platform-wallet-storage): refresh Cargo.lock for workspace dev.…
lklimek May 27, 2026
0ff215e
refactor(platform-wallet-storage)!: collapse V002 schema into V001 (P…
lklimek May 27, 2026
ffb8e1f
refactor(platform-wallet-storage)!: remove all deprecated mechanisms …
lklimek May 27, 2026
80b74c5
fix(platform-wallet-storage): symmetric wallet_id validation + COALES…
lklimek May 27, 2026
47972c0
fix(platform-wallet-storage): close commit_writes Immediate-mode gap …
lklimek May 27, 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
14 changes: 2 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 41 additions & 32 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1233,11 +1233,9 @@ impl PlatformWalletPersistence for FFIPersister {
}

if !round_success {
return Err(
"one or more persistence callbacks failed; changeset was rolled back"
.to_string()
.into(),
);
return Err(PersistenceError::backend(
"one or more persistence callbacks failed; changeset was rolled back",
));
}

// Merge into pending changesets.
Expand All @@ -1251,9 +1249,10 @@ impl PlatformWalletPersistence for FFIPersister {
if let Some(cb) = self.callbacks.on_store_fn {
let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) };
if result != 0 {
return Err(
format!("Persistence store callback returned error code {}", result).into(),
);
return Err(PersistenceError::backend(format!(
"Persistence store callback returned error code {}",
result
)));
}
}

Expand All @@ -1265,9 +1264,10 @@ impl PlatformWalletPersistence for FFIPersister {
if let Some(cb) = self.callbacks.on_flush_fn {
let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) };
if result != 0 {
return Err(
format!("Persistence flush callback returned error code {}", result).into(),
);
return Err(PersistenceError::backend(format!(
"Persistence flush callback returned error code {}",
result
)));
}
Comment on lines 1249 to 1271
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: [NEW] FFI callbacks return opaque i32, so every host-reported error is forced to PersistenceErrorKind::Fatalcommit_writes' retry contract cannot cross the boundary

The 47972c0 delta resolves the trait-level B7 symptom by deleting the blanket From<String> impls and forcing callers to construct PersistenceError::backend(...). At the FFI boundary the underlying problem persists: PersistenceCallbacks::on_store_fn, on_flush_fn, on_load_wallet_list_fn, and the shielded-load callbacks all return unsafe extern "C" fn(...) -> i32 — a single opaque code with no transient/fatal/constraint dimension. The persister consequently wraps every non-zero return via PersistenceError::backend(format!("...returned error code {}", result)), and backend(...) defaults to Fatal.

Consequence: a host that knows its error is transient (SwiftData CoreData store busy, transient I/O) has no way to signal that across the ABI, so the new commit_writes retry path treats every FFI-side failure as non-retryable and drops the buffered changeset. The trait now exposes backend_with_kind precisely to surface this distinction; the vtable needs a matching contract — reserved error-code ranges (e.g. >= 1000 = transient), or an additional out_kind: *mut u8 parameter — so hosts can opt in without ABI churn. Lower priority than the compile break; flag this as the next ABI revision target after the consumer-hardening PR lands.

source: ['claude']

}

Expand All @@ -1293,7 +1293,10 @@ impl PlatformWalletPersistence for FFIPersister {
let mut count: usize = 0;
let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) };
if rc != 0 {
return Err(format!("on_load_wallet_list_fn returned error code {}", rc).into());
return Err(PersistenceError::backend(format!(
"on_load_wallet_list_fn returned error code {}",
rc
)));
}
let _guard = LoadGuard {
context: self.callbacks.context,
Expand Down Expand Up @@ -2267,14 +2270,17 @@ fn build_wallet_start_state(
let xpub_bytes =
unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) };
let (account_xpub, _): (ExtendedPubKey, usize) =
bincode::decode_from_slice(xpub_bytes, config::standard())
.map_err(|e| format!("failed to decode account xpub: {}", e))?;
bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| {
PersistenceError::backend(format!("failed to decode account xpub: {}", e))
})?;
let account =
Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network)
.map_err(|e| format!("Account::from_xpub failed: {:?}", e))?;
accounts
.insert(account)
.map_err(|e| format!("AccountCollection::insert failed: {}", e))?;
.map_err(|e| {
PersistenceError::backend(format!("Account::from_xpub failed: {:?}", e))
})?;
accounts.insert(account).map_err(|e| {
PersistenceError::backend(format!("AccountCollection::insert failed: {}", e))
})?;
}

// External-signable wallet — the mnemonic / seed lives in the
Expand Down Expand Up @@ -2915,7 +2921,7 @@ fn build_unused_asset_locks(
for spec in specs {
// Decode the outpoint: 32-byte raw txid + 4-byte LE vout.
let txid = dashcore::Txid::from_slice(&spec.out_point[..32]).map_err(|e| {
PersistenceError::from(format!(
PersistenceError::backend(format!(
"tracked asset lock: invalid txid in outpoint: {}",
e
))
Expand All @@ -2928,8 +2934,8 @@ fn build_unused_asset_locks(

// Decode the consensus-encoded transaction.
if spec.transaction_bytes.is_null() || spec.transaction_bytes_len == 0 {
return Err(PersistenceError::from(
"tracked asset lock: empty transaction bytes".to_string(),
return Err(PersistenceError::backend(
"tracked asset lock: empty transaction bytes",
));
}
// SAFETY: Swift guarantees the buffer is valid for the
Expand All @@ -2939,7 +2945,7 @@ fn build_unused_asset_locks(
unsafe { slice::from_raw_parts(spec.transaction_bytes, spec.transaction_bytes_len) };
let transaction: dashcore::Transaction = dashcore::consensus::deserialize(tx_bytes)
.map_err(|e| {
PersistenceError::from(format!(
PersistenceError::backend(format!(
"tracked asset lock: failed to decode transaction: {}",
e
))
Expand All @@ -2959,7 +2965,10 @@ fn build_unused_asset_locks(
config::standard(),
)
.map_err(|e| {
PersistenceError::from(format!("tracked asset lock: failed to decode proof: {}", e))
PersistenceError::backend(format!(
"tracked asset lock: failed to decode proof: {}",
e
))
})?;
Some(proof)
};
Expand Down Expand Up @@ -3010,7 +3019,7 @@ fn funding_type_from_u8(
4 => AssetLockFundingType::AssetLockAddressTopUp,
5 => AssetLockFundingType::AssetLockShieldedAddressTopUp,
other => {
return Err(PersistenceError::from(format!(
return Err(PersistenceError::backend(format!(
"tracked asset lock: unknown funding_type discriminant {}",
other
)))
Expand All @@ -3027,7 +3036,7 @@ fn status_from_u8(b: u8) -> Result<platform_wallet::AssetLockStatus, Persistence
3 => AssetLockStatus::ChainLocked,
4 => AssetLockStatus::Consumed,
other => {
return Err(PersistenceError::from(format!(
return Err(PersistenceError::backend(format!(
"tracked asset lock: unknown status discriminant {}",
other
)))
Expand Down Expand Up @@ -3190,7 +3199,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result<AccountType, Persiste
// been UB for out-of-range bytes from a corrupt SwiftData row /
// forward-versioned tag / malformed host buffer).
let type_tag = AccountTypeTagFFI::try_from_u8(spec.type_tag).ok_or_else(|| {
PersistenceError::Backend(format!(
PersistenceError::backend(format!(
"AccountSpecFFI carries unknown type_tag byte {} (out of declared range)",
spec.type_tag
))
Expand All @@ -3199,7 +3208,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result<AccountType, Persiste
AccountTypeTagFFI::Standard => {
let standard_tag = StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag)
.ok_or_else(|| {
PersistenceError::Backend(format!(
PersistenceError::backend(format!(
"AccountSpecFFI(Standard) carries unknown standard_tag byte {}",
spec.standard_tag
))
Expand Down Expand Up @@ -3254,7 +3263,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result<AccountType, Persiste
// is gone.
AccountTypeTagFFI::IdentityAuthenticationEcdsa
| AccountTypeTagFFI::IdentityAuthenticationBls => {
return Err(PersistenceError::Backend(format!(
return Err(PersistenceError::backend(format!(
"AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType after the upstream event-bus refactor (TODO(events))",
type_tag
)));
Expand Down Expand Up @@ -3357,10 +3366,10 @@ fn restore_unresolved_asset_lock_tx_records(
let context = match rec.context_raw {
2 => {
let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| {
format!(
PersistenceError::backend(format!(
"load: malformed block_hash on unresolved asset-lock tx record: {}",
e
)
))
})?;
TransactionContext::InBlock(BlockInfo::new(
rec.block_height,
Expand All @@ -3370,10 +3379,10 @@ fn restore_unresolved_asset_lock_tx_records(
}
3 => {
let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| {
format!(
PersistenceError::backend(format!(
"load: malformed block_hash on unresolved asset-lock tx record: {}",
e
)
))
})?;
TransactionContext::InChainLockedBlock(BlockInfo::new(
rec.block_height,
Expand Down
29 changes: 24 additions & 5 deletions packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@ path = "src/bin/platform-wallet-storage.rs"
required-features = ["cli"]

[dependencies]
# Cross-cutting deps (always on)
platform-wallet = { path = "../rs-platform-wallet", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
# Truly cross-cutting deps (always on regardless of features).
thiserror = "1"
tracing = "0.1"
hex = "0.4"

# SQLite-backed persister deps (gated by the `sqlite` feature).
# `platform-wallet` types are reachable through the `sqlite` submodule
# only; without the feature the bare crate ships no items that mention
# them, so the wallet/serde graph stays out of the build (CODE-020).
# `dpp` types reach the persister via `IdentityPublicKey` (identity_keys
# writer), `AssetLockProof` (asset_locks writer) and `Identifier`
# (dashpay writer). `dash-sdk` is here for the `AddressFunds` re-export
# in `schema/platform_addrs.rs`. Feature set mirrors sibling
# `rs-platform-wallet` so the resolver picks identical hashes.
platform-wallet = { path = "../rs-platform-wallet", features = [
"serde",
], optional = true }
serde = { version = "1", features = ["derive"], optional = true }
key-wallet = { workspace = true, optional = true }
dashcore = { workspace = true, optional = true }
dpp = { path = "../rs-dpp", optional = true }
Expand All @@ -50,7 +55,6 @@ refinery = { version = "0.9", default-features = false, features = [
# (which derives bincode 2 `Encode`/`Decode`) and decode
# `dpp::AssetLockProof` from the asset-lock blob column.
bincode = { version = "2", optional = true }
fs2 = { version = "0.4", optional = true }
tempfile = { version = "3", optional = true }
chrono = { version = "0.4", default-features = false, features = [
"clock",
Expand All @@ -74,19 +78,34 @@ filetime = "0.2"
tracing-test = { version = "0.2", features = ["no-env-filter"] }
serial_test = "3"
platform-wallet-storage = { path = ".", features = ["sqlite", "cli", "__test-helpers"] }
# `round_trip_consumer.rs` constructs a real `PlatformWalletManager`
# (consumer) against a real `SqlitePersister` (this crate's impl) so
# every consumer↔persister contract drift becomes a CI failure (CODE-008
# / T-024). The manager needs `dash-sdk::SdkBuilder::new_mock().build()`
# (gated behind `mocks`) and `platform-wallet` requires `wallet` on the
# SDK transitively. Tokio is needed directly so `#[tokio::test]`
# resolves the macro by name.
dash-sdk = { path = "../rs-sdk", default-features = false, features = [
"dashpay-contract",
"dpns-contract",
"wallet",
"mocks",
] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

[features]
default = ["sqlite", "cli"]
# SQLite-backed persister (`platform_wallet_storage::sqlite`).
sqlite = [
"dep:platform-wallet",
"dep:serde",
"dep:key-wallet",
"dep:dashcore",
"dep:dpp",
"dep:dash-sdk",
"dep:rusqlite",
"dep:refinery",
"dep:bincode",
"dep:fs2",
"dep:tempfile",
"dep:chrono",
"dep:sha2",
Expand Down
Loading
Loading