Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
700153d
feat(platform-wallet): consumer hardening (CODE-001/CODE-003-callsite…
lklimek May 27, 2026
ad8f685
docs(platform-wallet-ffi): TODO comments at half-wired callback sites…
lklimek May 27, 2026
bbebfc2
docs(platform-wallet): document orphan-bucket convention on flush + c…
lklimek May 27, 2026
5a6ee3a
docs(platform-wallet): document default WalletId = orphan convention …
lklimek May 27, 2026
8979ad7
test(platform-wallet-storage): consumer↔SqlitePersister round-trip in…
lklimek May 27, 2026
ff00639
Merge remote-tracking branch 'origin/feat/platform-wallet-sqlite-pers…
lklimek May 27, 2026
a670704
Merge remote-tracking branch 'origin/fix/3625-thepastaclaw-hardening'…
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
12 changes: 1 addition & 11 deletions Cargo.lock

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

32 changes: 29 additions & 3 deletions packages/rs-platform-wallet-ffi/src/identity_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use std::time::Duration;

use platform_wallet::wallet::platform_wallet::WalletId;
use platform_wallet::{IdentityTokenSyncInfo, IdentityTokenSyncState};

use crate::error::*;
Expand Down Expand Up @@ -314,25 +315,47 @@ unsafe fn read_token_ids(ptr: *const u8, count: usize) -> Option<Vec<dpp::prelud
Some(out)
}

/// Register an identity with the token-sync registry.
/// Register an identity with the token-sync registry, bound to its
/// parent wallet.
///
/// `identity_id_ptr` must point to a 32-byte identifier.
/// `wallet_id_ptr` must point to the parent wallet's 32-byte id —
/// **required**, not optional. The recorded `WalletId` flows through
/// every `persister.store(wallet_id, …)` call this manager makes for
/// `identity_id`, so the changeset cascades through the correct wallet
/// rather than the legacy all-zero orphan sentinel.
/// `token_ids_ptr` must point to `token_ids_count * 32` bytes (each
/// 32-byte chunk is one token id) — null is permitted only when
/// `token_ids_count == 0` (registers the identity with no watched
/// tokens). Idempotent: a second call replaces the row.
/// tokens). Idempotent: a second call replaces the row, including the
/// recorded parent wallet binding.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_identity_sync_register_identity(
handle: Handle,
identity_id_ptr: *const u8,
wallet_id_ptr: *const u8,
token_ids_ptr: *const u8,
token_ids_count: usize,
) -> PlatformWalletFFIResult {
check_ptr!(identity_id_ptr);
check_ptr!(wallet_id_ptr);
let mut id_bytes = [0u8; 32];
std::ptr::copy_nonoverlapping(identity_id_ptr, id_bytes.as_mut_ptr(), 32);
let identity_id = dpp::prelude::Identifier::from(id_bytes);

let mut wallet_bytes: WalletId = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_ptr, wallet_bytes.as_mut_ptr(), 32);
// Reject the all-zero sentinel explicitly: this entry point exists
// to propagate a real parent wallet, and callers that genuinely
// want the orphan registration must not reach the wallet-aware
// path.
if wallet_bytes.iter().all(|b| *b == 0) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"wallet_id is the all-zero sentinel; pass a real parent wallet id",
);
}

let Some(token_ids) = read_token_ids(token_ids_ptr, token_ids_count) else {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorNullPointer,
Expand All @@ -342,7 +365,10 @@ pub unsafe extern "C" fn platform_wallet_manager_identity_sync_register_identity

let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| {
let mgr = manager.identity_sync_arc();
runtime().block_on(async move { mgr.register_identity(identity_id, token_ids).await });
runtime().block_on(async move {
mgr.register_identity_with_wallet(identity_id, Some(wallet_bytes), token_ids)
.await
});
});
unwrap_option_or_return!(option);
PlatformWalletFFIResult::ok()
Expand Down
14 changes: 11 additions & 3 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,11 @@ impl PlatformWalletPersistence for FFIPersister {
fn load(&self) -> Result<ClientStartState, PersistenceError> {
// If Swift hasn't wired up `on_load_wallet_list_fn` there's
// nothing to restore — treat as a fresh client.
// TODO(CODE-012): enforce paired (on_load_wallet_list_fn,
// on_load_wallet_list_free_fn) at registration time per
// thepastaclaw review on PR #3625. Deferred to a separate
// FFI-hardening PR — this gap pre-existed on v3.1-dev and is
// not introduced by #3625.
let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else {
return Ok(ClientStartState::default());
};
Expand Down Expand Up @@ -1538,6 +1543,9 @@ impl PlatformWalletPersistence for FFIPersister {
};
use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType};

// TODO(CODE-013): same as CODE-012 — enforce paired
// (on_get_core_tx_record_fn, on_get_core_tx_record_free_fn) at
// registration time. Deferred to FFI-hardening PR.
let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else {
return Ok(None);
};
Expand Down Expand Up @@ -3190,7 +3198,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 +3207,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 +3262,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
22 changes: 18 additions & 4 deletions packages/rs-platform-wallet-ffi/src/shielded_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,28 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded(
// *and* the per-network sync-coordination registry; we hand it
// to `bind_shielded` so the wallet reuses the shared store and
// self-registers its viewing keys for the coordinator-driven
// sync loop.
// sync loop. We also pull the manager's shared shielded
// start-state snapshot here (CODE-017) so the wallet's restore
// step skips its own `persister.load()` — when several wallets
// bind at startup, every call reuses the same cached `Arc`.
let lookup = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| {
runtime().block_on(async {
let wallet = manager.get_wallet(&wallet_id).await;
let coordinator = manager.shielded_coordinator().await;
(wallet, coordinator)
let cached_snapshot = manager.cached_persisted_shielded().await;
(wallet, coordinator, cached_snapshot)
})
});
let (wallet_arc, coordinator) = unwrap_option_or_return!(lookup);
let (wallet_arc, coordinator, cached_snapshot) = unwrap_option_or_return!(lookup);
let cached_snapshot = match cached_snapshot {
Ok(snap) => snap,
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("failed to load cached shielded snapshot: {e}"),
);
}
};
let wallet_arc = match wallet_arc {
Some(w) => w,
None => {
Expand All @@ -335,10 +348,11 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded(
}
};

if let Err(e) = runtime().block_on(wallet_arc.bind_shielded(
if let Err(e) = runtime().block_on(wallet_arc.bind_shielded_with_snapshot(
seed.as_ref(),
accounts.as_slice(),
&coordinator,
cached_snapshot,
)) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
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
59 changes: 32 additions & 27 deletions packages/rs-platform-wallet-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,20 @@ structured so a future `SecretStore` (currently sketched in
transient SQLite failure (`SQLITE_BUSY` / `SQLITE_LOCKED`) the
buffered changeset is merged back into the per-wallet buffer (LWW
with anything `store()`-d during the failed transaction) and the
call returns a `PersistenceError::Backend(_)` whose payload contains
the marker `flush failed transiently`. **Retry the call** — do not
discard state. Fatal failures (integrity check, encode error, mutex
poison, …) drop the buffer and surface verbatim.
call returns a `PersistenceError::Backend { kind: Transient, source }`
whose source carries the marker `flush failed transiently`.
**Retry the call** — do not discard state. Fatal failures (integrity
check, encode error, mutex poison, …) return `kind: Fatal` (or
`kind: Constraint` for SQL constraint violations) and drop the buffer.

The full classification lives on
[`WalletStorageError::is_transient`](src/sqlite/error.rs); the
boundary mapping into `PersistenceError::Backend(String)` flattens
the `Display` chain so operators can grep for variant names + hex
wallet ids in production logs.
[`WalletStorageError::is_transient`](src/sqlite/error.rs) and the
companion [`WalletStorageError::persistence_kind`](src/sqlite/error.rs)
that selects the trait-side kind. The `source` field is a
`Box<dyn Error + Send + Sync>` over the original `WalletStorageError`
— operators can walk `Error::source()` for the full typed chain;
the outer `Display` carries the variant marker + hex wallet id so
production-log greps still work.

## load() reconstruction

Expand Down Expand Up @@ -94,14 +98,18 @@ platform-wallet-storage --db <path> backup --out <dir-or-file>
platform-wallet-storage --db <path> restore --from <backup.db> --yes
platform-wallet-storage --db <path> prune --in <dir> [--keep-last N] [--max-age 30d]
platform-wallet-storage --db <path> inspect [--wallet-id <hex>] [--format text|tsv|json]
platform-wallet-storage --db <path> delete-wallet --wallet-id <hex> --yes [--no-auto-backup]
```

Destructive subcommands (`restore`, `delete-wallet`) REQUIRE `--yes`
— invoking them without it exits 2 with a usage error. `--no-auto-backup`
opts out of the pre-migration / pre-delete auto-backup respectively;
the library API has no equivalent opt-out (it routes to
[`SqlitePersister::delete_wallet_skip_backup`] internally).
Destructive subcommands (`restore`) REQUIRE `--yes` — invoking them
without it exits 2 with a usage error. `--no-auto-backup` opts out of
the pre-restore (or pre-migration) auto-backup; it is the supported way
to disable auto-backup. The historical sentinel `--auto-backup-dir ""`
also disables it but is **deprecated** and emits a warning — prefer the
explicit `--no-auto-backup` flag.

Wallet removal is a library-only API
([`SqlitePersister::delete_wallet`] / `delete_wallet_skip_backup`);
no CLI subcommand exposes it.

Logging: `-v` / `-vv` / `-vvv` enable `info` / `debug` / `trace`
respectively on stderr; `-q` suppresses non-error output.
Expand All @@ -111,19 +119,16 @@ validation failure (e.g. corrupt backup source).

## Operational notes

**Restore advisory-lock warning.** `restore` takes an exclusive `flock(2)`
on the destination DB and holds it across the entire restore body, so a
concurrent writer can't race the atomic swap. On filesystems where
advisory locking is unsupported (some NFS / FUSE / network mounts), the
crate emits a `tracing::warn!` on the
`platform_wallet_storage` target —

> `advisory lock unsupported on this filesystem; concurrent-writer race possible`

— and proceeds anyway (there's no alternative on such filesystems).
If you see this warning, ensure no other process opens the destination
DB during the restore window, or move the DB to a filesystem with flock
support before restoring.
**Restore exclusion.** `restore` opens a short-lived writer connection
on the destination DB and holds a SQLite-native `BEGIN EXCLUSIVE`
transaction across the entire restore body. This interlocks with every
other SQLite peer — sibling `SqlitePersister` handles, bare
`rusqlite::Connection` instances, the CLI — so concurrent writes back
off via SQLite's `busy_timeout` instead of racing the atomic swap. If a
peer holds the destination busy for longer than the timeout, `restore`
returns `WalletStorageError::RestoreDestinationLocked`. The lock conn is
released BEFORE the rename so SQLite's file handle on the old inode goes
away before the new DB takes its place.

**Manual-mode drop diagnostic.** `SqlitePersister` configured with
[`FlushMode::Manual`] emits a `tracing::error!` on drop if the buffer
Expand Down
Loading
Loading