feat(platform-wallet)!: consumer hardening — CODE-001/003-callsite/017/018 + PROJ-001 FFI + CODE-008/012/013#3750
Draft
Claudius-Maginificent wants to merge 41 commits into
Conversation
… (CODE-004)
Replace `PersistenceError::Backend(String)` with
`Backend { kind: PersistenceErrorKind, source: Box<dyn Error + Send + Sync> }`
so the persistor's `is_transient()` contract reaches consumers
without round-tripping through a stringified message.
`PersistenceErrorKind` carries the retry policy explicitly —
`Transient` (caller MAY retry), `Constraint` (SQL constraint /
FK / integrity, caller bug), `Fatal` (everything else). The kind
enum is wildcard-free at every consumer match site so a future
addition forces an explicit classification update.
Persistor side: `From<WalletStorageError> for PersistenceError`
delegates to a new `WalletStorageError::persistence_kind` that
folds in `is_transient()` plus SQLite `ConstraintViolation`
detection. The boxed source preserves the typed `WalletStorageError`
chain so consumers can `Error::source()`-walk to the inner rusqlite
payload — the previous `DisplayChain` flattening goes away.
Backward compat:
- `PersistenceError::backend(source)` defaults to `Fatal` kind.
- `PersistenceError::backend_with_kind(kind, source)` for callers
that classify (the storage `From` impl).
- `From<String>` / `From<&str>` still work and default to `Fatal` —
keeps FFI's `format!(...).into()` sites compiling.
This is a breaking change for any out-of-tree impl that
pattern-matched `PersistenceError::Backend(String)`. In-tree consumer
call sites still only log + proceed; the semantic upgrade to inspect
`is_transient()` + retry is T-003 (Wave 2).
Tests:
- `platform-wallet/tests/persistence_error_taxonomy.rs` — 5 tests
covering TC-CODE-004-a (kind shape + exhaustiveness), -c (source
is `Send + Sync` + Display), -e (`From<String>` defaults to Fatal).
- `platform-wallet-storage/tests/persistence_error_kind_mapping.rs` —
7 tests covering TC-CODE-004-b (transient / constraint / fatal
mapping table + chain preservation) and TC-CODE-004-e
(wildcard-free invariant guard on `is_transient` / `error_kind_str`
outer-Self match via source-text parse).
- TC-CODE-004-d (consumer-side retry on Transient) is T-003 per
Nagatha's plan, not T-001.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…ty refs (CODE-002)
Drops direct `wallet_id` FK columns from every identity-owned table.
The cascade chain becomes wallet_metadata → identities → identity-owned
objects so the consumer can pass the real owning `wallet_id` (or NULL
for orphan identities) without inventing the `WalletId::default()`
sentinel that V001's FK silently rejected.
Schema changes (V001 → V002):
- identities: PK (identity_id); wallet_id BLOB nullable, FK cascade
- identity_keys: drop wallet_id; PK (identity_id, key_id); FK identity_id
- dashpay_profiles: drop wallet_id; PK identity_id; FK identity_id
- dashpay_payments_overlay: drop wallet_id; PK (identity_id, payment_id); FK identity_id
- token_balances: drop wallet_id; PK (identity_id, token_id); FK identity_id
- wallet-scoped tables (accounts, core_*, platform_*, asset_locks,
contacts_*, wallet_metadata) unchanged
Sentinel-row guard: V002 refuses to run if `token_balances` carries
legacy rows with `wallet_id = X'00…00'`. Implemented via a CHECK on a
temp table; the persister open path re-classifies the failure as
`WalletStorageError::MigrationRequiresManualCleanup { table, count }`
so operators see what to drop instead of a cryptic rusqlite error.
Migration discipline: `run_for_open` disables `PRAGMA foreign_keys`
before BEGIN (and re-enables with assertion on exit). SQLite fires
`ON DELETE CASCADE` on children when their parent is dropped during
the 12-step ALTER, even under `defer_foreign_keys` — wiping the rows
we're migrating. FKs cannot be toggled inside a tx, hence the
wrapper.
Consumer side (`identity_sync.rs`): removed the
`WalletId::default()` sentinel. `IdentitySyncManager` now carries an
identity → `Option<WalletId>` map populated via the new
`register_identity_with_wallet` method; `apply_fresh_balances` looks
the wallet id up and passes it to `persister.store(...)`. The legacy
`register_identity` shim defaults to `None` (orphan) so FFI callers
continue to compile unchanged — V002's nullable
`identities.wallet_id` accepts the orphan case.
PER_WALLET_TABLES gains a `WalletScope { DirectColumn, ViaIdentity }`
discriminant; counting/inspect queries now route through
`count_rows_for_wallet_sql` so identity-scoped tables JOIN through
`identities` to find rows belonging to a wallet. `delete_wallet` is
unaffected — cascade still flows from `wallet_metadata`.
Tests:
- NEW `sqlite_v002_migration.rs` (6 TCs covering rows-preserved,
cascade-chain, orphan identity, real wallet_id token write,
sentinel-row refusal end-to-end, post-migration foreign_key_check)
- Existing migration / round-trip / delete-wallet / hardening tests
updated to the V002 schema (column lists, identity row seeding via
the new `ensure_identity` helper)
- Error-classification table extended with the new variant; gate
remains wildcard-free
Closes CODE-002.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… on load (CODE-001) `load_from_persistor` used to iterate over the persister's `wallets` map and drain `platform_addresses` per wallet inside the loop. When the persister reports `LOAD_UNIMPLEMENTED = &["ClientStartState::wallets"]` (today's `SqlitePersister` contract, pending PR #3692), the loop body never executes — and every persisted platform-address slice silently drops at function scope. The host's per-wallet `register_wallet` re-fetch path does in fact deliver the addresses correctly, so there is no actual data loss on PR #3625 today. But the silent-drop is a code smell that will absolutely bite the next caller who reaches for `load_from_persistor` expecting the documented contract. Gate the consumer: when `wallets.is_empty() && !platform_addresses.is_empty()`, return a new typed `PlatformWalletError::PersistorMissingWalletRehydration` carrying the unimplemented-area list and the orphan slice count. The host then either waits for #3692 to land or falls back to per-wallet `register_wallet`. Tests: - TC-CODE-001-a: persister returning two orphan platform_addresses slices → `PersistorMissingWalletRehydration { orphan_addresses_count: 2 }` - Negative control: empty persister payload (the `NoPlatformPersistence` shape) still succeeds — gate only trips for the orphan case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…register_wallet (CODE-018)
`register_wallet` used to ignore the result of the registration-time
`persister.store(...)` call — just log on error and proceed with the
wallet half-registered (visible in `wallet_manager`, no
`wallet_metadata` row on disk). Every subsequent per-wallet write
then FK-violates against the missing parent (CODE-002 territory).
Drive the typed `PersistenceError` kind off the wire (introduced by
CODE-004 / T-001):
- `Transient` (e.g. `SQLITE_BUSY`): one 50ms backoff retry; if the
retry succeeds the wallet registers normally.
- `Fatal` / `Constraint` / `LockPoisoned`: undo the in-memory
`wallet_manager` insert and surface the new
`PlatformWalletError::WalletRegistrationFailed { wallet_id, reason }`
variant.
The retry path uses `tokio::time::sleep` (already in tree) and clones
the registration changeset once — `PlatformWalletChangeSet` already
derives `Clone`.
Tests:
- TC-CODE-018-a: scripted `Fatal` store → returns
`WalletRegistrationFailed`, single store attempt (no retry),
`wallet_ids()` is empty.
- TC-CODE-018-b: scripted `Transient → Ok` → succeeds, wallet appears
in `wallet_ids()`, store called at least twice (original + retry).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… trait + wire remove_wallet (CODE-003) Surface SQLite's cascade-delete through the trait so consumers don't need a concrete backend. `DeleteWalletReport` moves from `platform-wallet-storage` into `platform-wallet::changeset::traits` (the trait owns its return types) — SqlitePersister keeps its inherent `delete_wallet` (returns `WalletStorageError` for callers that want the typed error) and adds a trait impl that delegates through the existing `delete_wallet_inner` helper, mapping the `WalletStorageError` chain into a classified `PersistenceError`. `PlatformWalletManager::remove_wallet` now calls `persister.delete_wallet` after the in-memory cleanup. Error policy mirrors `register_wallet` (CODE-018): transient → one backoff retry; anything else → log structured context and continue (the user wanted this wallet gone, in-memory state is already cleaned up, orphan rows can be reaped by an admin tool out-of-band). Trait default returns an empty `DeleteWalletReport` keyed by the requested id, so backends with no per-wallet disk state (`NoPlatformPersistence`, `FFIPersister`) inherit cleanly with no explicit impl required. TC-CODE-003-1/2/3 exercise happy path + fatal-error + transient-retry through `PlatformWalletManager::remove_wallet` with a recording persister. `tests/sqlite_trait_dispatch.rs` adds two more cases for the trait default and the SQLite trait impl round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… trait (CODE-026) Surface SQLite's `commit_writes` (batch flush of every dirty wallet in `FlushMode::Manual`) through the trait so consumers using deferred-write mode can durable-flush without a concrete backend type. `CommitReport` moves from `platform-wallet-storage` into `platform-wallet::changeset::traits` alongside the trait itself. SqlitePersister splits the inherent `commit_writes` body into a private `commit_writes_inner` helper that both the inherent and trait method delegate to — no behavioral drift, no duplication. Trait default returns an empty `CommitReport`, so backends that flush inline (`FlushMode::Immediate` on SQLite) or have nothing to buffer (`NoPlatformPersistence`, `FFIPersister`) inherit cleanly with no explicit impl required. TC-CODE-026-1/2 cover the default-impl empty-report contract and the SQLite trait round-trip flushing two dirty wallets durably. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…flock; drop fs2 dep (CODE-005/007/010/015) Cross-process exclusion for `restore_from` and `delete_wallet` now uses SQLite-native `BEGIN EXCLUSIVE` against the destination DB instead of the prior `fs2::FileExt::try_lock_exclusive` advisory lock. Advisory `flock(2)` doesn't interlock with SQLite's own page locking, so a peer rusqlite Connection could race the restore swap (CODE-005) or commit rows between the pre-delete backup snapshot and the cascade delete (CODE-007). With the SQLite-native primitive peers conflict for real: they wait on `busy_timeout`, then surface BUSY / RestoreDestinationLocked. `restore_from` opens a short-lived RW connection on the destination, issues `BEGIN EXCLUSIVE` (mapped to RestoreDestinationLocked on BUSY/LOCKED), and DROPS that connection BEFORE `tmp.persist` so SQLite releases its file handle on the old inode before the atomic rename. `delete_wallet` runs the pre-delete backup BEFORE acquiring EXCLUSIVE (rusqlite's online backup can't establish itself when the source conn is in an active write tx), then takes EXCLUSIVE for the cascade. A structured `info!` fires when the wallet's row footprint differs across the EXCLUSIVE acquisition so operators can correlate peer-committed-between-snapshot-and-lock scenarios. A `TODO(T-007/CODE-006)` marks the pre-backup flush slot that T-007 will fill inside the same EXCLUSIVE window. `fs2` is removed from `Cargo.toml`, the dep feature gate, and `Cargo.lock` (zero remaining transitive uses). The stale README "advisory-lock warning" and `backup.rs` rustdoc are rewritten to describe the real (SQLite-native) primitive. The Err arm that emitted `"advisory lock unsupported on this filesystem"` (CODE-010 false-positive downgrade) is gone with the flock block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tes (CODE-006)
`delete_wallet`'s pre-delete backup snapshot now contains every buffered
write that was pending at the moment of delete — not just the
already-persisted state. Without this fix, rollback-from-backup could
not recover a wallet whose only state lived in the merge buffer
(`FlushMode::Manual` + un-flushed).
Implementation: after draining the wallet's buffered changeset, the
delete path opens its own EXCLUSIVE tx and applies the changeset via
the newly extracted `apply_changeset_to_tx` helper (factored out of
`write_changeset_in_one_tx` so both call sites share schema-write
logic). The pre-flush commits BEFORE `run_auto_backup` runs, so the
snapshot captures the previously-buffered rows. On flush failure the
buffer is restored via the existing CMT-002 restore path and the
delete is aborted with no backup left behind.
The cascade-side backup still runs before the cascade's own
`BEGIN EXCLUSIVE` (rusqlite's `Backup::new` can't establish a backup
whose source connection holds an active write tx — `sqlite3_backup_step`
deadlocks against the in-flight EXCLUSIVE). A cross-process peer that
mutates the wallet between snapshot and lock is handled by the
existing post-EXCLUSIVE re-check, which logs the footprint change and
proceeds — the cascade reports zero per-table counts when the peer
beat it to every row.
Tests (TC-CODE-006-{1,2,3}):
- `pre_delete_backup_includes_buffered_writes`: renamed from
`pre_delete_backup_excludes_buffered_writes`; assertion flipped to
require the buffered rows to appear in the backup.
- `pre_flush_failure_preserves_buffer_and_skips_backup`: primes the
pre-flush injector to fail; asserts buffer survives, backup dir is
empty, wallet still in live DB, original error propagated.
- `peer_delete_between_backup_and_exclusive_returns_ok_with_zero_counts`:
arms a post-backup hook that opens a sibling raw connection and
deletes the wallet's metadata row in the gap between snapshot and
EXCLUSIVE; asserts the cascade returns Ok with zero counts and the
backup carries the pre-peer-deletion state.
Test-only helpers added (gated by `cfg(test)` / `__test-helpers`):
- `arm_post_backup_hook` — fires once between backup and EXCLUSIVE.
- `force_next_pre_flush_to_fail` — primes a one-shot pre-flush failure.
- `buffer_has_changeset_for_test` — probes the dirty-wallets set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…11/014/016/019)
Five small fixes in the SQLite persistor layer, batched into one commit
to keep the diff atomic (4 of 5 tasks touch `sqlite/backup.rs`).
* CODE-009 — `backup::run_to` swaps the pre-`exists()` gate for
`tempfile::persist_noclobber`. The atomic check-and-rename closes
the TOCTOU window; `AlreadyExists` maps to the typed
`BackupDestinationExists` so the CLI's contract stays intact.
* CODE-011 — Sidecar paths in `apply_secure_permissions` and the
`backup::restore_from` WAL/SHM-unlink loop are built via
`OsString::push` (non-UTF-8 path bytes round-trip intact). The
`set_permissions` / `remove_file` calls run unconditionally and
treat `ErrorKind::NotFound` as a silent no-op, removing the
`exists()` TOCTOU gate.
* CODE-014 — After every `tempfile::persist*` rename in
`backup::run_to` and `backup::restore_from`, the destination's
parent directory is `fsync`-ed via the new `fsync_parent_dir`
helper (Unix only; no-op on non-Unix). Rustdoc `# Atomicity`
sections updated to mention the parent-dir fsync.
* CODE-016 — `run_integrity_check` switches from `query_row` to
`query_map`-collect. All diagnostic rows land in the
`IntegrityCheckFailed { report }` string joined with `\n`; a
mid-stream `SqliteFailure` (DatabaseCorrupt) is treated as
end-of-stream when at least one row arrived (and appended as a
marker), preserving the existing `atom_013` semantics.
* CODE-019 — `backup::prune` now increments `kept` on a
`remove_file` failure (the file is still on disk). Documented the
invariant `kept + removed.len() == total_eligible` and the
`failed_removals ⊆ kept` subset relationship on `PruneReport`.
Tests:
* TC-CODE-009-a/b — atomic overwrite refusal + Io variant mapping
(`sqlite_backup_restore.rs`).
* TC-CODE-011-a/b/c — non-UTF-8 sidecar chmod, missing-sidecar Ok
path, source-level regression for both call sites
(`sqlite_permissions.rs`).
* TC-CODE-014-a/b — source-level fsync invocation count + rustdoc
mention assertions (`sqlite_backup_restore.rs`).
* TC-CODE-016-a — multi-page corruption yields a multi-line
diagnostic report (`sqlite_open_integrity_check.rs`).
* TC-CODE-019-a — chmod-locked prune dir produces
`kept + removed == total` with `failed_removals` populated
(`sqlite_backup_restore.rs`).
T-010 fsync is verified at the source level (regression-grade
test asserting `fsync_parent_dir(` appears in `backup.rs` at least
three times — definition + run_to + restore_from). A real
crash-durability harness is impractical in unit-test scope; the
rustdoc + source-level checks pin the contract.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…qlite feature (CODE-020) Mark `platform-wallet` and `serde` `optional = true` and route them through the existing `sqlite` feature via `dep:` activators. Without the feature the bare crate ships only `thiserror`, `tracing` and `hex` (20 lines of cargo tree -e normal, down from 1124 with default features), so embedders that only want the future `SecretStore` submodule no longer transitively pull `platform-wallet` -> `dpp`/`drive`/`dashcore`. Add `tests/feature_flag_build.rs` as a TC-CODE-020-1 source-level pin so a future contributor flipping the manifest back to unconditional deps trips a unit test instead of only the bare-build CI gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ces (CODE-021) The `delete-wallet` subcommand was removed from the maintenance CLI in favour of the library-only `SqlitePersister::delete_wallet` / `delete_wallet_skip_backup` APIs. The README still listed the subcommand and described `--no-auto-backup` as opting out of the pre-delete auto-backup — both stale. Scope the destructive-subcommand paragraph to `restore` only and steer readers at the library API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-022/023) The concurrent-store delete-wallet regression test silently swallowed both the `delete_wallet` Result (with `let _ = ...`) and the post-delete `COUNT(*)` query failure (with `.unwrap_or(0)`). Either mishap would have masked a real schema or API regression behind a green test — `unwrap_or(0)` is especially toxic because it would let a broken schema look like a clean wipe. Promote both call sites to fail loud: `expect(...)` for the delete itself and `unwrap_or_else(|e| panic!(...))` for the COUNT (cheaper than `.expect(&format!(...))` so clippy stays quiet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ruption (CODE-024) The single-page corruption helper guarded against `len > 4096`, but it then reads page 2 (bytes 4096..8192) — that bound is off by one and silently corrupts a partially-allocated page on a freshly-opened DB small enough to land in the [4097, 8191] window. Tighten the assertion to `>= 8192` so the precondition matches what the helper actually does, and include the observed length in the panic message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…per (CODE-025) The CLI binary carried its own `default_auto_backup_dir_for_cli` mirror of the persister's helper — two definitions of the same `<db_dir>/backups/auto/` resolution, one drift bug away from the CLI and the library disagreeing about where auto-backups live. Promote the persister-side helper to `pub` (the originally preferred `pub(super)` is incompatible with `pub use` re-exports up to the crate root — documented in the helper's rustdoc), re-export it from `sqlite/mod.rs` and the crate root, and switch the CLI to call the library helper. Single source of truth, zero behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix-up for 7dff6a4 — rustfmt prefers the closing `)` on a separate line when the raw string spans multiple lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_wallet N+1 load() (CODE-017) Add an Option<BTreeMap<WalletId, PlatformAddressSyncStartState>> cache (+ shielded snapshot under cfg(feature = "shielded")) on PlatformWalletManager, populated by the first call into `ensure_persisted_state_loaded` and consumed by `load_from_persistor` and `register_wallet`. `remove_wallet` drops the per-wallet entry so a future re-registration under the same id cannot apply stale state. Before: `register_wallet` called `persister.load()` per wallet — M wallets = M full reads, each holding the connection mutex. After: exactly one `load()` for the whole register round, whether triggered by an explicit `load_from_persistor` or lazily by the first `register_wallet`. bind_shielded still calls persister.load() per-wallet; routing it through the cache requires either a manager-side bind_shielded entrypoint or back-references from PlatformWallet to the manager — out of scope for this CODE-017 patch and tracked separately. New test file `tests/persister_load_cache.rs`: - TC-CODE-017-a: load_from_persistor + N register_wallet = 1 load - TC-CODE-017-b: cold-start (no load_from_persistor) + N register = 1 load - TC-CODE-017-c: remove_wallet invalidates only the removed slice; re-register under same id triggers no additional load Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(CODE-027) DRY out the 4 in-storage `SELECT 1 FROM sqlite_master WHERE name = 'refinery_schema_history'` probes into a single `migrations::has_schema_history` helper. Callers (`open`, `count_pending`, `restore_from` source + staged) now share one implementation; the `assert_schema_version_supported` table-presence check piggy-backs on the same helper. Adds TC-CODE-027-1 (in-module unit test): helper returns false on a fresh in-memory DB and true once the table is created. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…es! nits (CODE-028/029)
CODE-029 — extend `validate_config`:
* journal_mode = Memory → reject (rollback journal in RAM is
crash-unsafe for a wallet DB)
* journal_mode = Off → reject (no rollback journal at all)
* busy_timeout = 0 → warn via `tracing::warn!` (operator
almost certainly didn't mean it; a few tests want fail-fast, so we
don't reject)
Reuses the existing `ConfigInvalid { reason: &'static str }` variant —
no new variant or field, so wildcard-free matches on
`WalletStorageError` keep compiling.
CODE-028 — replace `matches!(self.config.flush_mode, FlushMode::X)`
with `==`/`!=` at the two persister sites. FlushMode already derives
`PartialEq, Eq`, so this is a pure readability cleanup with no semantic
change.
Tests added (sqlite_persist_roundtrip.rs):
* TC-CODE-029-1 — journal_mode=Memory rejected with typed
ConfigInvalid; DB not created.
* TC-CODE-029-2 — journal_mode=Off rejected with typed
ConfigInvalid; DB not created.
* TC-CODE-029-3 — busy_timeout=Duration::ZERO opens successfully and
emits the expected `busy_timeout=0` warning (verified via
`tracing_test::traced_test`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in favour of --no-auto-backup (CODE-030)
The CLI used to overload an empty string passed to `--auto-backup-dir`
as "disable auto-backup". That's a non-standard convention and
collides with the dedicated `--no-auto-backup` flag already exposed by
`migrate` and `restore`.
Deprecation strategy: keep the empty-string form parsing for one
release so existing operator scripts don't break overnight, but emit a
loud `warning: ... deprecated; pass --no-auto-backup instead` on
stderr the moment the legacy form is used. The arg's help text and the
README's CLI section steer new callers to `--no-auto-backup`.
Tests (sqlite_cli_smoke.rs):
* TC-CODE-030-1a — `migrate --no-auto-backup` succeeds, emits the
expected stderr notice, and writes no pre-migration snapshot.
* TC-CODE-030-1b — `--auto-backup-dir "" migrate --no-auto-backup`
still succeeds but emits the deprecation warning steering to
`--no-auto-backup`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-012/013) Mark the two half-wired-callback gaps thepastaclaw flagged on PR #3625 with TODO comments that pin them to follow-up issues. Both gaps pre-existed on v3.1-dev — #3625 didn't introduce them — and a proper fix needs FFI registration plumbing (paired (fn, free_fn) enforcement) that's out of scope for this PR. * CODE-012 — FFIPersister::load: on_load_wallet_list_fn / on_load_wallet_list_free_fn must be paired at registration time. * CODE-013 — FFIPersister::get_core_tx_record: same pairing requirement for on_get_core_tx_record_fn / on_get_core_tx_record_free_fn. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration tests (CODE-008) Add `tests/round_trip_consumer.rs` — the meta-fix CI safety net for PR #3625. Five `#[tokio::test]`s exercise a real `PlatformWalletManager` (consumer side from `rs-platform-wallet`) against a real `SqlitePersister` (this crate) so every consumer↔ persister contract drift now fails CI instead of slipping through: * TC-CODE-008-1 — register_wallet → drop → reopen: wallet_metadata + account_registrations + account_address_pools survive. * TC-CODE-008-2 — platform_addresses round-trip through persister.store + list_per_wallet across reopen. * TC-CODE-008-3 — identity_keys + token_balances round-trip under a registered (wallet, identity) pair; regression guard for CODE-002 (sentinel WalletId::default() FK violation). * TC-CODE-008-4 — remove_wallet cascades through storage for the removed wallet but leaves the surviving sibling untouched; regression guard for CODE-003 (remove_wallet never propagated to disk). * TC-CODE-008-5 — boot the manager twice over the same DB; the persisted wallets are still on disk after a clean reopen + a second load_from_persistor() pass; regression guard for CODE-001. Per user direction ("If possible, put it into persister crate") the test lives here so the dev-dep cycle stays one-way: `platform-wallet` ships no dependency on `platform-wallet-storage`, while the storage crate is free to pull `platform-wallet` into `[dev-dependencies]` for integration coverage. Dev-deps added: `dash-sdk` with `mocks` + `wallet` features (needed by `SdkBuilder::new_mock().build()` for the manager) and a direct `tokio` so `#[tokio::test]` resolves the macro by name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entry point (PROJ-001) The FFI `platform_wallet_manager_identity_sync_register_identity` was still routing through the orphan-identity shim (`IdentitySyncManager::register_identity`) which records `Option<WalletId>::None` and ultimately persists the resulting `TokenBalanceChangeSet` under the all-zero `WalletId` sentinel. That defeats the purpose of the wallet-aware path (`register_identity_with_wallet`) and breaks the `wallet_metadata → identities → token_balances` cascade for any token balance learned through this FFI. This commit: * Adds a required `wallet_id_ptr` parameter (32 bytes) to the FFI entry point, rejecting null with `ErrorNullPointer` and the all-zero sentinel with `ErrorInvalidParameter`. * Routes to `register_identity_with_wallet(identity_id, Some(wallet_id), token_ids)` so the recorded parent wallet flows through every subsequent `persister.store(wallet_id, …)` call. * Updates the Swift wrapper `registerIdentityForTokenSync` to take the new `walletId: Data` argument, validate length on the Swift side, and marshal the buffer through the new FFI parameter. * Updates the SwiftExampleApp caller to source the parent wallet from `identity.wallet?.walletId`, skipping the registration (and the display-only fetch still works) when the identity is out-of-wallet. * Updates the doc-comment usage example in `TokenActions.swift`. BREAKING CHANGE: the C ABI for `platform_wallet_manager_identity_sync_register_identity` gains a new `wallet_id_ptr` parameter between `identity_id_ptr` and `token_ids_ptr`. Swift callers must pass the parent wallet id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ities upsert Document that the `wallet_id = COALESCE(excluded.wallet_id, identities.wallet_id)` upsert clause is the intended merge semantic for orphan-identity-to-wallet promotion (NULL `wallet_id` is allowed per the V002 CODE-002 design). Existing `wallet_id` survives a re-upsert; a freshly known `wallet_id` fills NULL on promotion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… orphan-cleanup contract Document that orphan `token_balances` cleanup is the host's responsibility — no automatic prune API is offered. V002 cascades only through `identities` (the `wallet_id` column was dropped from `token_balances`), so hosts that delete identities out-of-band must prune the corresponding rows themselves. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-017 follow-up) Helper added in 6934404 was never called, tripping -D dead-code on macOS strict profile and blocking Swift SDK build on PR #3743. Wiring fulfills the original perf intent (drop N+1 persister.load() at shielded bind time). - Add PlatformWallet::bind_shielded_with_snapshot taking a pre-loaded Arc<ShieldedSyncStartState>; bind_shielded keeps its public signature by delegating with None. - Promote PlatformWalletManager::cached_persisted_shielded from pub(super) to pub so the FFI layer (the only direct host of bind_shielded) can fetch the shared snapshot once per call. - platform_wallet_manager_bind_shielded now pulls the cached snapshot alongside the coordinator and feeds it through, so every wallet's bind reuses the same Arc instead of issuing its own load(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ar chmod test (APFS compat) tc_code_011_a previously used raw non-UTF-8 bytes (0xff 0xfe ...) which APFS rejects with EILSEQ. The test's real intent is to verify OsString sidecar concatenation + chmod survive non-ASCII paths — a valid-UTF-8 multi-byte sequence (ÿþ → \xc3\xbf\xc3\xbe) exercises the same codepath on both Linux ext4/btrfs and macOS APFS without losing coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on persistence trait Trait-level contract: passing WalletId::default() to PlatformWalletPersistence methods marks the entity as orphan (no parent wallet). V002 schema permits this; higher layers (FFI) may enforce stricter rules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ommit/delete reports Follow-up to d0ed23b: extend the WalletId::default() = orphan convention to PlatformWalletPersistence::flush parameter, commit_writes return doc, CommitReport field docs, and DeleteWalletReport.wallet_id field.
…epastaclaw-hardening # Conflicts: # packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
…3) — moved to follow-up PR Phase 2 of PR #3743 slim. Restores consumer-side state to origin/feat/platform-wallet-sqlite-persistor for these files. The trait surface (delete_wallet, commit_writes, typed PersistenceError) stays so the storage crate still compiles; only the consumer call-sites and caching/retry plumbing revert. - src/error.rs: drop PersistorMissingWalletRehydration + WalletRegistrationFailed variants - src/manager/load.rs: drop CODE-001 orphan-bucket gate and CODE-017 cache plumbing - src/manager/mod.rs: drop CODE-017 cache fields on PlatformWalletManager - src/manager/wallet_lifecycle.rs: drop CODE-018 retry, CODE-003 wired remove_wallet call site, cache-invalidation hook - src/wallet/platform_wallet.rs: drop CODE-017 bind_shielded_with_snapshot shim - FFI src/shielded_sync.rs: drop CODE-017 cached_persisted_shielded wiring Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ck sites (CODE-012/013)" This reverts commit 6602cdb.
…lush + commit/delete reports" This reverts commit 5747a98.
…vention on persistence trait" This reverts commit d0ed23b.
…8 consumer-integration test moved to follow-up PR) This test brings rs-platform-wallet as a dev-dep specifically to exercise the consumer flow against the storage backend, and it depends on the PROJ-001 wallet-aware register_identity_with_wallet helper. Now that the PROJ-001 consumer-side wiring has been reverted from this PR (kept for the follow-up consolidated PR), the test no longer compiles. Other storage-crate-internal round-trip coverage (sqlite_persist_roundtrip, sqlite_load_reconstruction, etc.) remains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/CODE-017/CODE-018) + PROJ-001 FFI - CODE-001: refuse silent drop of orphan platform_addresses on load - CODE-003 (call-site): wire remove_wallet to persister.delete_wallet - CODE-017: cache ClientStartState slices to drop register_wallet N+1 load() - CODE-018: retry transient + undo on fatal store error in register_wallet - PROJ-001: FFI register_identity requires wallet_id (Rust + Swift + SwiftExampleApp) - 5 consumer integration tests covering the above Originally landed on PR #3743 hardening branch; extracted here to keep #3743 focused on the rs-platform-wallet-storage crate landing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-012/013) Originally landed on PR #3743; extracted here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ommit/delete reports Originally landed on PR #3743; extracted here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on persistence trait Originally landed on PR #3743; extracted here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration tests (CODE-008) Originally landed on PR #3743; extracted here. Lives under rs-platform-wallet-storage/tests/ but brings rs-platform-wallet as dev-dep to exercise the consumer flow against the storage backend — fundamentally a consumer-integration test, follows the consumer code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…istor' into fix/3625-thepastaclaw-hardening
… into feat/platform-wallet-consumer-hardening
Base automatically changed from
fix/3625-thepastaclaw-hardening
to
feat/platform-wallet-sqlite-persistor
May 27, 2026 09:42
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Consumer-side hardening for the platform-wallet stack, extracted from PR #3743 to keep that PR focused on the
rs-platform-wallet-storagecrate landing + the minimal trait surface that crate needs.Stack chain
When #3625 merges, #3743 rebases onto v3.1-dev. When #3743 merges, this PR rebases onto #3625's merged tip. Standard chain — each layer cleanly isolates a concern.
What's in this PR
Consumer logic (rs-platform-wallet)
platform_addresseson load —PlatformWalletError::PersistorMissingWalletRehydrationraised when persister returns addresses but no wallets.remove_walletto callpersister.delete_wallet(...)(the trait method itself ships in feat(platform-wallet)!: rs-platform-wallet-storage crate (SQLite persister) + trait surface #3743).ClientStartStateslices inPlatformWalletManagerto dropregister_walletN+1load()calls; helperstake_persisted_platform_addresses,cached_persisted_shielded,invalidate_persisted_for_wallet.register_wallet—PlatformWalletError::WalletRegistrationFailed { wallet_id, reason }.FFI (rs-platform-wallet-ffi)
platform_wallet_manager_identity_sync_register_identitynow requireswallet_id_ptr: *const u8(32 bytes, rejects null + all-zero sentinel), routes to wallet-awareregister_identity_with_wallet. Breaking C ABI change.Swift SDK
PlatformWalletManagerIdentitySync.swiftgainswalletId: Dataparameter,TokenActions.swiftdoc-example updated,IdentityDetailView.swiftsourceswalletIdfromidentity.wallet?.walletId.Trait docs (rs-platform-wallet)
WalletId::default()= orphan convention onPlatformWalletPersistence::{store, flush, get_core_tx_record, delete_wallet, commit_writes}.CommitReport.{succeeded, failed, still_pending}andDeleteWalletReport.wallet_id.Tests
packages/rs-platform-wallet/tests/:load_from_persistor.rs,persistence_error_taxonomy.rs,persister_load_cache.rs,register_wallet_failure.rs,remove_wallet_delete.rs.packages/rs-platform-wallet-storage/tests/round_trip_consumer.rs(CODE-008) — lives under storage/tests/ but brings rs-platform-wallet as dev-dep.Breaking changes
platform_wallet_manager_identity_sync_register_identitygains requiredwallet_id_ptrparameter betweenidentity_id_ptrandtoken_ids_ptr. Swift wrappers updated. Out-of-wallet identities are now rejected at this entry point (orphan-aware registration is a future follow-up — see platform-wallet: orphan identities cannot be loaded — persistor writes them, but no consumer-side reader #3745).PlatformWalletErrorvariants (PersistorMissingWalletRehydration,WalletRegistrationFailed).Test plan
cargo fmt --all -- --checkcargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warningscargo test -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi— 424 passed, 0 failed (incl. all 5round_trip_consumertests and the 5 new consumer integration test files)Related
feat/platform-wallet-sqlite-persistor) — root of the stack: persister base.fix/3625-thepastaclaw-hardening) — immediate base: rs-platform-wallet-storage crate + trait surface (typedPersistenceError,delete_wallet,commit_writes) this PR's consumer code depends on.🤖 Generated with Claude Code