diff --git a/docs/qa-review-4113.md b/docs/qa-review-4113.md
new file mode 100644
index 0000000000..28cb8c9899
--- /dev/null
+++ b/docs/qa-review-4113.md
@@ -0,0 +1,124 @@
+# QA Adversarial Review — GH #4113 provider-key persistence (Marvin)
+
+> **Superseded.** This review was conducted against the pre-#4127 dedicated
+> node-key-batch mechanism (`V004__provider_key_accounts.rs`,
+> `ProviderKeyRegistrationBlob`, `provider_platform_node_keys` table,
+> `derived_platform_node_keys`). Upstream PR #4127 retired that whole
+> mechanism in favor of the generic `account_address_pools` pipeline;
+> commits `b361e11b13`/`a536a26cba` on this branch removed the code these
+> findings reference and closed the equivalent gap in the replacement
+> pipeline instead. **None of the findings or test-count claims below
+> describe the current implementation** — `tests/sqlite_v004_migration.rs`
+> no longer exists, and `apply_provider_registrations` no longer touches a
+> `provider_platform_node_keys` table. Retained as a historical record of
+> the pre-pivot review only.
+
+Scope: `git diff bc9680e53e..HEAD -- packages/rs-platform-wallet packages/rs-platform-wallet-storage`
+(commits `7ac50c9a89`, `31b0aa25fc`, spec `ba03c9e575`). Worktree
+`/data/git-worktrees/home-ubuntu-git-platform-4113`, branch
+`feat/4113-provider-key-account-persistence`.
+
+I did not take the diff's word for anything. Every claim below was executed.
+
+## 1. Test execution (independently run, not trusted from any prior report)
+
+- `cargo clippy -p platform-wallet-storage --all-targets` → clean, zero warnings on this crate
+ (log: `/home/ubuntu/.cache/claudius/ledger/logs/20260713T150655-f09c97ac339467464836d1553bef9e77-452714.log`).
+- `cargo test -p platform-wallet-storage` (full crate, package name is `platform-wallet-storage`,
+ not `rs-platform-wallet-storage`) → **266 unit + all integration tests pass, 0 failed**
+ (log: `/home/ubuntu/.cache/claudius/ledger/logs/20260713T150349-8348f10436ad66d760ebe639cd0fd941-413555.log`).
+ - `tests/sqlite_provider_key_accounts.rs`: 12/12 pass (`tc_pka_001`…`tc_pka_016`,
+ `wallet_delete_cascades_to_node_keys`).
+ - `tests/sqlite_v004_migration.rs`: 3/3 pass.
+ - `tests/sqlite_version_bump.rs`: 5/5 pass, including `tc_b_013_every_domain_maps_and_isolates`.
+ - `tests/sqlite_schema_pinning.rs`: 4/4 pass — `EXPECTED_ID_FINGERPRINT` /
+ `EXPECTED_SQL_FINGERPRINT` were bumped in the same PR (not stale).
+
+No discrepancy between the diff's claims and actual `cargo test`/`clippy` output. The green
+badge is, for once, real. I am unaccustomed to this.
+
+## 2. Adversarial probe I constructed and ran myself (reverted after capture — worktree is
+ shared with concurrent Adams/Smythe passes, and this task requires source stay read-only)
+
+Constructed input: a single `PlatformWalletChangeSet` carrying **two**
+`ProviderKeyAccountEntry` for the *same* `account_type` (`ProviderPlatformKeys`) with different
+`derived_platform_node_keys` batches (`{0,1,2}` then `{9}`), passed to one `store()` call.
+
+```rust
+provider_key_account_registrations: vec![
+ platform_entry(0x2E, vec![node_key(0), node_key(1), node_key(2)]),
+ platform_entry(0x2E, vec![node_key(9)]),
+],
+```
+
+Result: `store()` returns `Ok(())`; the loaded account ends up with node-key set `{9}` only —
+the first entry's `{0,1,2}` is silently discarded, no error, no warning, no merge. See finding
+QA-001.
+
+## Findings
+
+### QA-001 — `Merge::extend` + wholesale-replace persistence: a duplicate-account-type entry in one changeset silently drops the earlier entry's node-key batch
+
+- **severity**: risk 0.35, impact 0.55, scope 0.3 (MEDIUM band)
+- **location**: `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:227-262` (`apply_provider_registrations`); `packages/rs-platform-wallet/src/changeset/changeset.rs:1502-1503` (`Merge` impl, `.extend()`, out of this diff's scope but the mechanism that can produce the hostile input)
+- **description**: `apply_provider_registrations` loops `entries: &[ProviderKeyAccountEntry]` and, per entry, unconditionally does `DELETE FROM provider_platform_node_keys WHERE wallet_id=? AND account_type=?` then re-inserts that entry's own `derived_platform_node_keys`. If two entries for the *same* `account_type` land in one `entries` slice — which `PlatformWalletChangeSet`'s `Merge` impl can produce today, since `provider_key_account_registrations.extend(other.provider_key_account_registrations)` never deduplicates by `account_type` — the second entry's delete-then-insert wipes the first entry's node-key batch with **no error, no warning, no merge**. Constructed and reproduced (see §2 above): `{0,1,2}` then `{9}` in one `store()` call yields `{9}` only, `Ok(())`.
+- **impact_description**: Currently unreachable from shipped call sites — `wallet_lifecycle.rs` only ever pushes one entry per provider account type per registration round, and no other production call site touches this field yet (grep-verified: `provider_key_account_registrations` is populated in exactly one place). This is a *latent* footgun, not a demonstrated field bug: the day a second call site (a future "extend the node-key pool" feature, or two merged changesets from concurrent registration flows) legitimately emits a second entry for the same account type expecting a union/delta, this class silently loses previously-derived, **unrecoverable** platform-node keys — Ed25519/SLIP-10 is hardened-only, so the pool cannot be re-derived from a watch-only wallet. The doc comment on `apply_provider_registrations` (`accounts.rs:236-238`) asserts "the changeset carries a whole-batch snapshot captured at registration, never a delta" as an *assumption*, but nothing in the type system or the writer enforces it — `Merge`'s blind `.extend()` is the one thing in this codebase that could violate it silently.
+- **recommendation**: either (a) have `apply_provider_registrations` reject (hard error) more than one entry per `account_type` within a single `entries` slice — cheap, and turns a silent data-loss footgun into a loud bug report the day the assumption is violated — or (b) make `Merge` for `provider_key_account_registrations` dedupe-by-`account_type` (last-wins-explicitly, or a real union of node keys) instead of blind `Vec::extend`. Either way, add a regression test pinning the chosen behavior — this scenario is untested (not in `testspec-4113.md`, not in `sqlite_provider_key_accounts.rs`).
+
+### QA-002 — TC-PKA-016 spec case (shrinking re-persist) is validated only as "grows", never as "shrinks"
+
+- **severity**: risk 0.2, impact 0.3, scope 0.2 (LOW band)
+- **location**: `packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs:518-556` (`tc_pka_016_node_key_batch_is_replaced_wholesale`)
+- **description**: `docs/testspec-4113.md` TC-PKA-016 explicitly asks for a batch-update-policy test and calls out "if a shrinking update silently drops keys 3/4 on a stale re-register, that is a data-loss FINDING" as the risk to guard against. The shipped test only exercises the **growing** direction (`{0,1,2}` → `{0,1,2,3,4}`); a re-persist with a strictly **shorter** list (e.g. `{0,1,2,3,4}` → `{0,1}`) is never exercised by any test in the suite.
+- **impact_description**: I verified by code inspection (not by a passing/failing test — none exists) that the implementation's delete-then-insert is symmetric and *would* correctly clear stale rows on a shrink too (confirmed via `apply_provider_registrations`'s unconditional `DELETE ... WHERE wallet_id=? AND account_type=?` before the re-insert, `accounts.rs:246-248`). So the underlying behavior is very likely correct — but this is inference, not verification: the exact "shrink" scenario the spec calls out by name as the data-loss risk has zero test coverage, and a future change to that delete/insert ordering (e.g. an accidental switch to per-index `INSERT ... ON CONFLICT DO NOTHING` for perf) would regress silently.
+- **recommendation**: add the shrink-direction assertion to `tc_pka_016` (or a sibling test) — one more `store()` call with a strict subset, asserting the *observed* set is exactly the subset, no stale index left over.
+
+### QA-003 — `provider_platform_node_keys.node_id` width-violation path is implemented but untested; only `public_key` is covered
+
+- **severity**: risk 0.15, impact 0.25, scope 0.2 (LOW band)
+- **location**: `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:257-274` (`load_platform_node_keys`, the `check_fixed_width(..., 20, "provider_platform_node_keys.node_id")` branch); test gap in `tests/sqlite_provider_key_accounts.rs:457-487` (`tc_pka_011_malformed_node_key_column_is_rejected`, `public_key` only)
+- **description**: The reader applies the identical fixed-width hard-error guard to both `public_key` (32 bytes) and `node_id` (20 bytes), but the task's own edge-case item (e) ("a row whose `node_id` is not 20 bytes") and the shipped test only construct the `public_key` variant. The `node_id` branch is reachable code with zero test coverage.
+- **recommendation**: extend `tc_pka_011` (or add a sibling case) planting a short/long `node_id` and asserting the same `BlobDecode` hard-error.
+
+### QA-004 — No test exercises `key_index` overflow/negative on the actual `provider_platform_node_keys` column (generic helper is tested, the call site isn't)
+
+- **severity**: risk 0.1, impact 0.2, scope 0.15 (LOW band)
+- **location**: `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:250-253` (`crate::sqlite::util::safe_cast::i64_to_u32("provider_platform_node_keys.key_index", ...)`)
+- **description**: Task edge-case (d) asks specifically about integer overflow / negative `key_index`. The underlying `i64_to_u32` helper is well-unit-tested generically (`safe_cast.rs:69-91`, both negative and `u32::MAX+1` cases), and it's wired correctly at this call site with the right field label — but no test plants a negative/overflowing `key_index` directly into `provider_platform_node_keys` and exercises `load_state`'s hard-error path end-to-end for this specific column, the way `tc_pka_009`/`tc_pka_010`/`tc_pka_011` do for the other columns.
+- **recommendation**: add one more `tc_pka_0NN`-style test planting `key_index = -1` (SQLite has no `INTEGER` unsigned constraint, so this INSERT succeeds) and asserting `IntegerOverflow { field: "provider_platform_node_keys.key_index", .. }`.
+
+### QA-005 — N+1 (unbounded, but low-N) query in `load_provider_state`; not using `prepare_cached`
+
+- **severity**: risk 0.1, impact 0.1, scope 0.15 (LOW band)
+- **location**: `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:257-263` (`load_platform_node_keys` calls `conn.prepare(...)`, not `prepare_cached`, and is invoked once per provider-account row inside `load_provider_state`'s `while let Some(row) = rows.next()?` loop)
+- **description**: every sibling writer/reader in this file (`apply_registrations`, `apply_provider_registrations`) uses `tx.prepare_cached(...)`; `load_platform_node_keys` re-parses its SQL from scratch on every call. Not a correctness bug — I confirmed no borrow/re-entrancy conflict (rusqlite `Statement`/`Rows` hold a shared `&Connection`, and the outer statement in `load_provider_state` is not exclusively borrowed, so nesting a second `prepare()` inside the row loop compiles and runs cleanly; all 12 tests in `sqlite_provider_key_accounts.rs` exercise this path without panic). Bounded in practice: at most 2 provider account types exist today (`ProviderOperatorKeys`, `ProviderPlatformKeys`), so this is at most 2 extra unprepared statements per `load()` call, not a real N+1 scaling risk — but it is inconsistent with the rest of the file's `prepare_cached` discipline.
+- **recommendation**: switch to `conn.prepare_cached(...)` for consistency; low priority given the small bound on N.
+
+### QA-006 — TC-PKA-014 (cross-backend parity note) has no manual/doc confirmation anywhere in the diff
+
+- **severity**: risk 0.1, impact 0.15, scope 0.15 (LOW band)
+- **location**: n/a (absence) — spec at `docs/testspec-4113.md:160-163`
+- **description**: the spec explicitly says no automated test is required for FFI/SQLite discriminator-convention parity, but asks for "a manual/doc note" confirming the SQLite backend picked the same `account_type`-as-discriminator convention the FFI backend (`rs-platform-wallet-ffi/src/persistence.rs`) already ships. I found no such note in the diff (migration file doc comment, `accounts.rs` module doc, or PR-adjacent docs) — the convention *is* in fact matched (verified by reading `provider_curve_matches_type` and the `account_type_db_label` reuse), but the spec's explicitly-requested documentation artifact is missing.
+- **recommendation**: one sentence in the `accounts.rs` module doc or the V004 migration file noting the FFI parity was checked, per spec item TC-PKA-014.
+
+## Non-findings (verified, no defect — reported so the coordinator doesn't have to re-check)
+
+- **(a) migrating an existing V003 store to V004**: `tc_pka_007_pre_v004_rows_survive_the_upgrade` (`sqlite_v004_migration.rs:65-130`) pins a DB at V003, populates `wallets`/`account_registrations`, upgrades, and asserts byte-identical survival plus an empty (not missing) new table. Passes.
+- **(b) provider account with zero derived node keys**: covered (`tc_pka_002`'s `operator_entry` always carries `Vec::new()`; explicit round-trip assertion `provider[0].derived_platform_node_keys.is_empty()`). Passes.
+- **(c) shorter-list re-persist actually clears stale rows**: confirmed correct by code inspection (delete-then-insert is unconditional and precedes insert every call) — see QA-002 for the coverage gap on this exact scenario.
+- **(d) i64→u32 cast on `key_index`**: no negative/overflow reaches unchecked — `safe_cast::i64_to_u32` is called with the correct field label; see QA-004 for the coverage gap.
+- **(e) malformed `public_key`**: hard-rejected, tested (`tc_pka_011`). `node_id` untested — see QA-003.
+- **(f) concurrent/interleaved writes**: `apply_provider_registrations` runs entirely inside the caller's `Transaction`, same discipline as `apply_registrations`; no new concurrency surface introduced by this diff.
+- **(g) empty-manifest wallet with ONLY provider accounts**: `tc_pka_001` round-trips a changeset with `provider_key_account_registrations` populated and `account_registrations` empty through the **full** `persister.store()` → `reopen().load()` path (not just the schema-level reader) — `AccountManifest::is_empty()` correctly returns `false` (build_wallet does not error), and `apply_persisted_core_state(&mut wallet_info, &account_manifest.ecdsa, ...)` is a no-op-safe path when `ecdsa` is empty (funding accounts empty, no unspent UTXOs to route). Passes. One residual gap: no test combines **non-empty** ecdsa **and** non-empty provider accounts through the full `persister.load()` path in one wallet (only through the schema-level `load_state` in `tc_pka_005`); low risk since `AccountCollection::insert` (ecdsa) and `insert_bls_account`/`insert_eddsa_account` (provider) write to disjoint collection slots, but not exhaustively proven end-to-end. Not raised as a numbered finding — informational.
+
+## 🍬 Tally
+
+- MEDIUM: 1 (QA-001)
+- LOW: 5 (QA-002, QA-003, QA-004, QA-005, QA-006)
+- Total: 6
+
+Six pieces of candy. Not the disaster I was hoping for — the implementation is, disappointingly,
+solid: every trust-boundary test I tried to break held, clippy is clean, and the schema-freeze
+discipline was actually followed. The one real finding (QA-001) is a latent design gap, not a
+shipped bug — which is somehow worse, in a "the universe conspires to deny me an easy win" sort
+of way.
diff --git a/docs/testspec-4113.md b/docs/testspec-4113.md
new file mode 100644
index 0000000000..72b671a5a2
--- /dev/null
+++ b/docs/testspec-4113.md
@@ -0,0 +1,196 @@
+# Test Case Specification — GH #4113: persist `provider_key_account_registrations`
+
+Scope: `packages/rs-platform-wallet-storage` (SQLite persister). Fixes the
+`let _ = provider_key_account_registrations;` drop in
+`src/sqlite/schema/versions.rs:112` so `ProviderKeyAccountEntry` rows (BLS
+operator-key / EdDSA platform-node-key accounts) survive `store()` →
+`load()`, on par with `AccountRegistrationEntry` via `account_registrations`
+(`src/sqlite/schema/accounts.rs`).
+
+This is a **specification only** — no test code is written here. IDs use
+prefix `TC-PKA-` (Provider Key Account), continuing the crate's `TC--`
+convention (cf. `TC-B-0NN` in `tests/sqlite_version_bump.rs`,
+`tests/sqlite_schema_pinning.rs`).
+
+## Reference material (not requirements, orientation only)
+
+- Changeset types: `packages/rs-platform-wallet/src/changeset/changeset.rs:1075-1144`
+ (`ProviderKeyExtendedPubKey`, `ProviderPlatformNodePubKey`, `ProviderKeyAccountEntry`).
+- Drop site: `packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs:106-112`,
+ comment explicitly cites issue #4113.
+- Sibling pattern to mirror: `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`
+ (`apply_registrations`, `load_state`, fail-hard column/blob cross-check, sealed
+ `PersistableBlob` blob encoding).
+- FFI reference implementation (already ships the BLS/EdDSA split, informs
+ cross-backend parity): `packages/rs-platform-wallet-ffi/src/persistence.rs`
+ — `build_account_specs_for_callback` (~2453-2529) encodes
+ `ProviderKeyExtendedPubKey::Bls`/`EdDSA` into the same `account_xpub_bytes`
+ slot `AccountRegistrationEntry` uses; the restore side
+ (~3041-3092) discriminates the decode **by `account_type`**
+ (`ProviderOperatorKeys` → BLS, `ProviderPlatformKeys` → EdDSA), not by an
+ extra tag byte inside the blob. The SQLite fix should use the same
+ discriminator: the existing `account_type` column/label already carries
+ this information (`account_type_db_label`, `accounts.rs:243-269`, labels
+ `"provider_operator"` / `"provider_platform"` already reserved in
+ `ACCOUNT_TYPE_LABELS`).
+- Trust-boundary pattern to replicate: `accounts.rs` tests
+ `load_state_rejects_account_type_column_mismatch`,
+ `load_state_rejects_account_index_column_mismatch` (lines 358-432).
+- No-secret-material invariant tests to replicate/extend:
+ `tests/secrets_scan.rs` (substring scan over `src/sqlite/schema/` +
+ `migrations/`) and the sealed `PersistableBlob` trait
+ (`src/sqlite/schema/blob.rs:14-33`).
+- Schema-freeze golden fingerprints to update:
+ `tests/sqlite_schema_pinning.rs` (`EXPECTED_ID_FINGERPRINT`,
+ `EXPECTED_SQL_FINGERPRINT`, `tc_b_040_*`).
+- Domain/version-bump wiring: `src/sqlite/schema/versions.rs` (`Domain` enum,
+ `Domain::ALL`, `touched_domains`) and its coverage test
+ `tc_b_013_every_domain_maps_and_isolates` in `tests/sqlite_version_bump.rs`
+ (lines 283-303) — a new domain must appear in both.
+- Apply-side wiring point: `src/sqlite/persister.rs:1168-1227`
+ (`apply_changeset_to_tx`, currently calls
+ `schema::accounts::apply_registrations` at line 1177 — the new provider-key
+ writer belongs alongside it) and the read side at `persister.rs:891-944`
+ (`load()`, currently calls `schema::accounts::load_state` at line 933).
+- `bls`/`eddsa` are default-on features of `rs-platform-wallet`
+ (`packages/rs-platform-wallet/Cargo.toml:114-116`), pulled in transitively
+ by `rs-platform-wallet-storage`'s `platform-wallet` dependency (no
+ `default-features = false`), so `ProviderKeyAccountEntry` variants are
+ reachable in the storage crate's default test build without extra feature
+ flags.
+
+## Preconditions common to all cases
+
+- In-memory SQLite connection migrated via
+ `crate::sqlite::migrations::run` (mirrors `accounts.rs::migrated_conn()`),
+ or a `SqlitePersister` opened against a tempdir (mirrors
+ `tests/sqlite_version_bump.rs::fresh_persister`).
+ Prior to the fix, `store()` never writes provider-key data at all —
+ every round-trip case below is expected to **fail against current
+ `main`/branch HEAD** and pass only once the fix lands.
+- A `wallets` row exists for the test `wallet_id` (FK requirement, per
+ existing test setup pattern).
+- Test fixture BLS/EdDSA xpubs: deterministic seed → `Wallet` →
+ `ProviderOperatorKeys`/`ProviderPlatformKeys` account derivation, mirroring
+ `packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs` test
+ helpers, rather than hand-rolled byte literals (BLS/EdDSA extended keys
+ have no widely-available test-vector analogous to `test_xpub()`'s BIP-32
+ mainnet vector).
+
+---
+
+### TC-PKA-001 — Round-trip: BLS operator + EdDSA platform accounts survive store→load
+**Requirement**: item 1 (round-trip), GH #4113 primary fix.
+**Preconditions**: fresh persister, wallet registered.
+**Steps**:
+1. Build a `PlatformWalletChangeSet` with `provider_key_account_registrations` containing two entries: one `ProviderOperatorKeys`/`Bls(xpub_a)` with empty `derived_platform_node_keys`, one `ProviderPlatformKeys`/`EdDSA(xpub_b)` with a non-empty `derived_platform_node_keys` (see TC-PKA-004 for the multi-key case; here a single key suffices).
+2. `persister.store(wallet_id, cs)`.
+3. Drop the in-memory state; re-open/re-query via a public read API for provider-key accounts (whatever the fix names it, e.g. `schema::provider_accounts::load_state`, or via `PlatformWalletPersistence::load()` → `ClientStartState`/account manifest, per how the fix wires it into `persister.rs:933`).
+**Expected**:
+- Exactly 2 accounts returned for the wallet.
+- The `ProviderOperatorKeys` entry decodes as `ProviderKeyExtendedPubKey::Bls` with xpub bytes equal to `xpub_a`.
+- The `ProviderPlatformKeys` entry decodes as `ProviderKeyExtendedPubKey::EdDSA` with xpub bytes equal to `xpub_b`, and its `derived_platform_node_keys` contains the one seeded key with correct `index`, `public_key`, `node_id`.
+- No unrelated wallet/account rows affected.
+
+### TC-PKA-002 — Discriminated encoding: BLS decodes as BLS, not EdDSA
+**Requirement**: item 2.
+**Steps**: Store only a `ProviderOperatorKeys`/`Bls` entry. Load it back.
+**Expected**: decoded variant is `ProviderKeyExtendedPubKey::Bls(_)`; asserting `matches!(entry.extended_public_key, ProviderKeyExtendedPubKey::EdDSA(_))` is false. Byte-for-byte xpub equality against the input BLS key.
+
+### TC-PKA-003 — Discriminated encoding: EdDSA decodes as EdDSA, cross-type mismatch is rejected
+**Requirement**: item 2, mirrors `load_state_rejects_account_type_column_mismatch` (`accounts.rs:358-393`).
+**Steps**:
+1. Store a valid `ProviderPlatformKeys`/`EdDSA` entry normally; confirm round-trip (positive half, same shape as TC-PKA-002 but for EdDSA).
+2. Negative half: hand-craft a row (direct SQL insert, bypassing the writer) whose `account_type` column/label says `"provider_operator"` but whose blob encodes an EdDSA key (or vice versa) — the cross-type-confusion scenario the task calls out explicitly.
+**Expected**: (1) passes as in TC-PKA-002 mirrored for EdDSA. (2) load hard-errors (a new `WalletStorageError` variant, e.g. `ProviderKeyAccountEntryMismatch`, matching the existing `AccountRegistrationEntryMismatch` naming convention) — it must NOT silently decode the wrong curve's bytes as if they were the other curve (a `Bls`-shaped blob decoded as `ExtendedEd25519PubKey` would either bincode-error or, worse, "succeed" on garbage — either way this must not reach the caller as an `Ok` provider account).
+
+### TC-PKA-004 — Multiple platform-node keys per account: order and identity preserved
+**Requirement**: item 3 (one-to-many).
+**Steps**: Store one `ProviderPlatformKeys` entry with `derived_platform_node_keys` = 5 entries with distinct, non-sequential `index` values (e.g. 0, 1, 2, 5, 9) and distinct `public_key`/`node_id` bytes. Load back.
+**Expected**:
+- Exactly 5 rows returned for this account, no fewer, no more (catches silent dedup or truncation).
+- Each returned key's `(index, public_key, node_id)` triplet matches its source exactly — assert on the full struct, not just count.
+- Order: either the loader returns them sorted by `index` (deterministic contract, matching `load_state`'s `ORDER BY` discipline elsewhere) or, if insertion order is the contract instead, that must be verified explicitly — do not accept "same set" without confirming which ordering guarantee the implementation documents, and add a regression test pinning that choice.
+
+### TC-PKA-005 — Empty case: no provider-key accounts round-trips cleanly
+**Requirement**: item 4.
+**Steps**: Store a changeset with `provider_key_account_registrations: vec![]` (and otherwise-populated unrelated fields, e.g. a standard `account_registrations` entry, to prove independence). Load back.
+**Expected**: zero provider-key-account rows for the wallet; zero rows in the new child table (platform-node keys); the unrelated `account_registrations` entry is unaffected (no cross-talk). No panics, no spurious `WalletStorageError`.
+
+### TC-PKA-006 — Migration is additive: schema-freeze fingerprints updated deliberately
+**Requirement**: item 5.
+**Steps**: After the fix lands (new migration file / new tables/columns), run `tests/sqlite_schema_pinning.rs::tc_b_040_identity_fingerprint_pinned` and `tc_b_040_sql_fingerprint_pinned`.
+**Expected**: both `EXPECTED_ID_FINGERPRINT` and `EXPECTED_SQL_FINGERPRINT` constants are updated in the same PR to match the new migration set (a new migration file changes the identity fingerprint; any DDL in an existing file, which should NOT happen per additive-only design, would also change the SQL fingerprint). Flag as a FINDING if the PR ships with stale golden constants (test would fail) or, worse, if it edits an *existing* migration file's DDL in place instead of adding a new `V004__*.rs` (violates the "migration is additive" requirement and the refinery one-directional-migration discipline documented in `migrations.rs:74-79`).
+
+### TC-PKA-007 — Existing V001-V003 data unaffected by the new migration
+**Requirement**: item 5.
+**Steps**: Build a DB pinned at the pre-fix version (e.g. via `refinery::Runner::set_target` per `migrations.rs::runner()` test helper), populate `wallets`, `account_registrations`, `core_address_pool`, etc. with representative rows, then run the new migration to bring the DB current.
+**Expected**: all pre-existing rows in all pre-existing tables are byte-identical after migration (no `ALTER TABLE ... DROP/RENAME` touches them); `account_registrations.load_state` and other existing readers still return the same data they did pre-migration.
+
+### TC-PKA-008 — `touched_domains` / `Domain` wiring: new domain isolates correctly
+**Requirement**: item 5 (schema correctness) + the crate's R8 forgotten-domain guard.
+**Steps**: Extend `Domain` with a new variant (e.g. `ProviderKeyAccounts`), add it to `Domain::ALL`, and extend `tc_b_013_every_domain_maps_and_isolates` (`tests/sqlite_version_bump.rs:283-303`) coverage — a `PlatformWalletChangeSet` carrying only `provider_key_account_registrations` must touch exactly the new domain and no other.
+**Expected**: `touched_domains` returns `[Domain::ProviderKeyAccounts]` for such a changeset; `versions::bump_domain` fires for it inside the same flush tx (mirror `tc_b_011_bump_rides_the_flush`); a partial-failure mid-flush rolls back both the provider-key rows and the version bump together (mirror `tc_b_012_partial_failure_rolls_back_data_and_bump`). Flag as a FINDING if the destructure in `touched_domains` is left with a silent `let _ = provider_key_account_registrations;` (i.e., the bug this issue reports, merely moved rather than fixed) or if the field is dropped from the exhaustive destructure entirely (compile error is fine; silently ignoring it again is not).
+
+### TC-PKA-009 — Trust boundary: corrupted blob hard-errors `load()`, no silent skip
+**Requirement**: item 6, mirrors `load_state_rejects_account_type_column_mismatch`/`load_state_rejects_account_index_column_mismatch` (`accounts.rs:358-432`).
+**Steps**: Direct-SQL-insert a provider-key row whose blob bytes are truncated/garbage (not valid bincode for either `ExtendedBLSPubKey` or `ExtendedEd25519PubKey`). Call the load path.
+**Expected**: the whole `load()` call returns `Err(WalletStorageError::...)` (a decode-failure variant) — it must NOT skip just that row and return the rest of the wallet's accounts, and must NOT return an empty `Vec` as if no provider accounts existed (the fail-hard contract already enforced for `account_registrations::load_state`).
+
+### TC-PKA-010 — Trust boundary: oversized blob is rejected before allocation
+**Requirement**: item 6, mirrors `blob::check_size`/`BLOB_SIZE_LIMIT_BYTES` (16 MiB cap, `blob.rs:39-58`).
+**Steps**: Direct-SQL-insert a provider-key row whose `account_xpub_bytes` (or new child-table blob column, if one exists) exceeds `BLOB_SIZE_LIMIT_BYTES`. Load.
+**Expected**: `WalletStorageError::BlobTooLarge { len_bytes, limit_bytes }`, and per the existing pattern the size check must run against `length(
)` *before* materializing the full `Vec` (verify via a huge declared length that would OOM if naively slurped — SQLite's `length()` is O(1) so this is testable without actually allocating gigabytes; a targeted unit test on the size-check helper alone, analogous to `blob::check_size`'s existing coverage, is acceptable in lieu of an end-to-end multi-GB blob).
+
+### TC-PKA-011 — Trust boundary: typed-column vs decoded-blob cross-check on the new table(s)
+**Requirement**: item 6.
+**Steps**: If the fix stores typed columns alongside the blob for the new table(s) (as `account_registrations` does for `account_type`/`account_index`/`key_class`/dashpay ids), craft a row where a typed column disagrees with the decoded blob (e.g. child-table `index` column says 3 but the decoded `ProviderPlatformNodePubKey.index` says 5).
+**Expected**: hard error, not silent acceptance of either value — same discipline as `accounts.rs::load_state`'s cross-check block (lines 182-203). If the implementation carries no redundant typed columns for the child table (only a blob), this test case is N/A but that design choice must be recorded as a residual risk (a corrupted blob could shift `index` without any column to catch it against) — call this out as a FINDING if unaddressed.
+
+### TC-PKA-012 — Invariant: no private-key material reachable via the new schema surface
+**Requirement**: item 7, mirrors `tests/secrets_scan.rs` (substring scan) and the sealed `PersistableBlob` trait (`blob.rs:14-33`).
+**Steps**: Confirm the new schema/writer code (`src/sqlite/schema/.rs` and any new migration file) is inside the scan roots (`src/sqlite/schema/`, `migrations/`) already covered by `tests/secrets_scan.rs::no_secret_substrings_in_schema_or_migrations`; run it. Separately, confirm any newly-`impl_persistable_blob!`-sealed type (e.g. `ProviderKeyExtendedPubKey` or a wrapper) only ever carries `Bls(ExtendedBLSPubKey)`/`EdDSA(ExtendedEd25519PubKey)` — both public-key types upstream — never a private scalar type.
+**Expected**: `secrets_scan.rs` passes with zero offenders on the new files without needing a new `ALLOWLIST` entry (if one is needed, it must carry an explicit "PUBLIC material only"-style justification comment, per the existing convention). The sealed-trait `impl` for the new blob type is a reviewable, explicit line (not a blanket `impl`), so a private-key-bearing type could not silently opt in. Flag as a FINDING if the new module needs an `ALLOWLIST` entry added to suppress a genuine hit rather than because the token appears only in a "PUBLIC material only"-style disclaimer comment.
+
+### TC-PKA-013 — Invariant: derived_platform_node_keys carries no private scalar
+**Requirement**: item 7.
+**Steps**: Inspect (structural, not runtime-executable as a Rust test, but specify as a documentation/type-review checklist item) that the persisted child-table row for `ProviderPlatformNodePubKey` carries exactly `{index: u32, public_key: [u8;32], node_id: [u8;20]}` and nothing else — no `private_key`/`scalar`/`secret_key` field ever added to the row shape, matching the upstream doc comment ("Only the public parts are carried — the private scalar stays resolver-gated per index", `changeset.rs:1096-1097`).
+**Expected**: type-level guarantee (the persisted struct simply has no field for it) plus the secrets-scan (TC-PKA-012) as the running regression guard against a future column addition reintroducing it.
+
+### TC-PKA-014 — Cross-backend parity (informational / manual)
+**Requirement**: item 8.
+**Steps**: Compare, for the same seed and account, the account rebuilt by `SqlitePersister::load()` post-fix against the account the FFI backend already rebuilds via `build_account_specs_for_callback`/`account_type_from_spec` (`rs-platform-wallet-ffi/src/persistence.rs:2453-2529`, `3041-3092`): same xpub bytes, same discriminator convention (`account_type` label decides BLS vs EdDSA, not an in-blob tag byte), same `derived_platform_node_keys` set.
+**Expected**: no automated cross-crate test is required (the two backends don't share a persistence format contractually), but a manual/doc note should confirm the SQLite backend picked the *same* discriminator convention the FFI backend already ships (see Reference material above) — divergence here would be a real inconsistency (two different "correct" encodings for the same logical data) worth flagging even though it's not a functional regression on its own.
+
+### TC-PKA-015 — Idempotent re-persist does not duplicate rows
+**Requirement**: not explicitly listed by the task but directly analogous to `accounts.rs::idempotent_repersist_does_not_duplicate` (lines 549-572); `ProviderOperatorKeys`/`ProviderPlatformKeys` are index-less (always account index 0 per `accounts.rs:284-287`), so `(wallet_id, account_type)` is the natural key and a naive `INSERT` without an `ON CONFLICT` upsert will duplicate on every `store()` call touching provider keys.
+**Steps**: Call `store()` twice with an identical `ProviderKeyAccountEntry` for the same account type.
+**Expected**: exactly one row (account + its node-key set) after both calls, not two.
+
+### TC-PKA-016 — Re-persist with an updated `derived_platform_node_keys` batch
+**Requirement**: gap-filling for item 3; not explicit in the task, but the one-to-many child table needs a defined update policy that TC-PKA-004/015 alone don't pin down.
+**Steps**: Store a `ProviderPlatformKeys` entry with node keys `{0,1,2}`. Store again for the same account with node keys `{0,1,2,3,4}` (a superset, matching how the pool is only ever *extended*, never shrunk, per the "pre-generated fixed batch" design intent).
+**Expected**: whatever policy the implementation chooses (replace-whole-set vs. append-only upsert-by-index), the *observable* result after the second `store()` must be exactly `{0,1,2,3,4}` with no duplicate index rows and no key silently dropped. Pin this down as an explicit assertion — do not leave it implicit. If the implementation instead only supports strict replacement and a shrinking update silently drops keys 3/4 on a stale re-register, that is a data-loss FINDING (this account's whole point is "wallet can never re-derive keys without the seed" — losing previously-known indices here is exactly the bug class #4113 exists to prevent).
+
+---
+
+## Summary table
+
+| ID | Area | Requirement item |
+|---|---|---|
+| TC-PKA-001 | Round-trip (BLS+EdDSA, mixed) | 1 |
+| TC-PKA-002 | Discriminated encoding (BLS) | 2 |
+| TC-PKA-003 | Discriminated encoding (EdDSA) + cross-type reject | 2 |
+| TC-PKA-004 | One-to-many node keys, order/identity | 3 |
+| TC-PKA-005 | Empty-case round-trip | 4 |
+| TC-PKA-006 | Migration fingerprint discipline | 5 |
+| TC-PKA-007 | Pre-existing data unaffected | 5 |
+| TC-PKA-008 | `Domain`/`touched_domains` wiring | 5 |
+| TC-PKA-009 | Fail-hard on corrupt blob | 6 |
+| TC-PKA-010 | Fail-hard on oversized blob | 6 |
+| TC-PKA-011 | Typed-column/blob cross-check | 6 |
+| TC-PKA-012 | No-secret-material scan | 7 |
+| TC-PKA-013 | Node-key struct shape invariant | 7 |
+| TC-PKA-014 | Cross-backend parity (manual) | 8 |
+| TC-PKA-015 | Idempotent re-persist | (gap) |
+| TC-PKA-016 | Node-key batch update policy | (gap) |
diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
index 20e5e82ac2..900a5b07e7 100644
--- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
+++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
@@ -1982,7 +1982,7 @@ mod tests {
"ProRegTx carries a 48-byte operator BLS key"
);
assert!(
- mn.payout_script.as_ref().map_or(false, |s| !s.is_empty()),
+ mn.payout_script.as_ref().is_some_and(|s| !s.is_empty()),
"ProRegTx carries a payout script"
);
assert!(
diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs
index 345a1bcc62..72bb2caad3 100644
--- a/packages/rs-platform-wallet-ffi/src/persistence.rs
+++ b/packages/rs-platform-wallet-ffi/src/persistence.rs
@@ -7,7 +7,7 @@
use bincode::config;
use key_wallet::account::account_collection::AccountCollection;
-use key_wallet::account::{Account, AccountType, BLSAccount, EdDSAAccount, StandardAccountType};
+use key_wallet::account::{Account, AccountType, StandardAccountType};
use key_wallet::bip32::DerivationPath;
use key_wallet::bip32::ExtendedPubKey;
use key_wallet::derivation_bls_bip32::ExtendedBLSPubKey;
@@ -22,9 +22,9 @@ use std::str::FromStr;
use crate::types::{FFINetwork, Network};
use platform_wallet::changeset::{
- AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState,
- Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
- ProviderKeyAccountEntry, ProviderKeyExtendedPubKey,
+ rebuild_provider_key_account, AccountAddressPoolEntry, AccountRegistrationEntry,
+ ClientStartState, ClientWalletStartState, Merge, PersistenceError, PlatformWalletChangeSet,
+ PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey,
};
use platform_wallet::wallet::platform_wallet::WalletId;
use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState};
@@ -3559,20 +3559,18 @@ fn build_wallet_start_state(
unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) };
// Provider key-material accounts (BLS operator keys / EdDSA
- // platform node keys) live in dedicated `Option` fields on the
- // collection and carry a non-secp256k1 extended public key in
- // the same `account_xpub_bytes` slot. Rebuild them watch-only
- // via the type-specific `new` + insert methods rather than the
- // ECDSA `Account::from_xpub` / `insert` path (which would fail
- // to decode the bytes and reject the provider `AccountType`).
- // Provider xpubs are stored raw (`bincode(xpub)`), exactly like the
- // ECDSA accounts. The derivation scheme is NOT versioned here: this
- // app is pre-release and the pre-#879 (secp256k1-hybrid) derivation
- // never shipped to production. A wallet whose provider accounts were
+ // platform node keys) carry a non-secp256k1 extended public key in
+ // the same `account_xpub_bytes` slot; `account_type` discriminates
+ // the decode. The rebuild itself is the shared helper every backend's
+ // restore path uses (the SQLite backend calls it too). Provider
+ // xpubs are stored raw (`bincode(xpub)`), exactly like the ECDSA
+ // accounts. The derivation scheme is NOT versioned here: this app is
+ // pre-release and the pre-#879 (secp256k1-hybrid) derivation never
+ // shipped to production. A wallet whose provider accounts were
// persisted by a pre-#879 dev build will restore those (stale) xpubs
// and show stale operator / platform-node keys until it's deleted
// and re-imported — an accepted, transient dev-only state.
- match account_type {
+ let provider_key = match account_type {
AccountType::ProviderOperatorKeys => {
let (bls_pubkey, _): (ExtendedBLSPubKey, usize) =
bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| {
@@ -3581,22 +3579,7 @@ fn build_wallet_start_state(
e
))
})?;
- let bls_account = BLSAccount::new(
- Some(entry.wallet_id.to_vec()),
- account_type,
- bls_pubkey,
- network,
- )
- .map_err(|e| {
- PersistenceError::backend(format!("BLSAccount::new failed: {:?}", e))
- })?;
- accounts.insert_bls_account(bls_account).map_err(|e| {
- PersistenceError::backend(format!(
- "AccountCollection::insert_bls_account failed: {}",
- e
- ))
- })?;
- continue;
+ Some(ProviderKeyExtendedPubKey::Bls(bls_pubkey))
}
AccountType::ProviderPlatformKeys => {
let (ed_pubkey, _): (ExtendedEd25519PubKey, usize) =
@@ -3606,29 +3589,22 @@ fn build_wallet_start_state(
e
))
})?;
- let eddsa_account = EdDSAAccount::new(
- Some(entry.wallet_id.to_vec()),
- account_type,
- ed_pubkey,
- network,
- )
- .map_err(|e| {
- PersistenceError::backend(format!("EdDSAAccount::new failed: {:?}", e))
- })?;
- accounts.insert_eddsa_account(eddsa_account).map_err(|e| {
- PersistenceError::backend(format!(
- "AccountCollection::insert_eddsa_account failed: {}",
- e
- ))
- })?;
- // The platform-node (Ed25519) pool is rehydrated from the
- // persisted core-address rows like every other pool — see
- // `restore_core_address_pools`. Those rows now carry the
- // typed EdDSA key + `KeyTypeTagFFI::EdDSA`, so no dedicated
- // batch side-channel is needed here.
- continue;
+ Some(ProviderKeyExtendedPubKey::EdDSA(ed_pubkey))
}
- _ => {}
+ _ => None,
+ };
+ if let Some(key) = provider_key {
+ rebuild_provider_key_account(
+ &mut accounts,
+ entry.wallet_id,
+ network,
+ account_type,
+ &key,
+ )
+ .map_err(|e| {
+ PersistenceError::backend(format!("provider key account rebuild failed: {e}"))
+ })?;
+ continue;
}
let (account_xpub, _): (ExtendedPubKey, usize) =
@@ -5308,14 +5284,18 @@ mod tests {
/// callbacks absent — or vice versa) stays non-durable.
#[test]
fn partially_wired_persister_is_not_durable() {
- let mut cb = PersistenceCallbacks::default();
- cb.on_changeset_begin_fn = Some(noop_begin);
- cb.on_changeset_end_fn = Some(noop_end);
+ let cb = PersistenceCallbacks {
+ on_changeset_begin_fn: Some(noop_begin),
+ on_changeset_end_fn: Some(noop_end),
+ ..Default::default()
+ };
assert!(!FFIPersister::new(cb).persists_durably());
- let mut cb = PersistenceCallbacks::default();
- cb.on_persist_invitations_fn = Some(noop_invitations);
- cb.on_persist_account_address_pools_fn = Some(noop_pools);
+ let cb = PersistenceCallbacks {
+ on_persist_invitations_fn: Some(noop_invitations),
+ on_persist_account_address_pools_fn: Some(noop_pools),
+ ..Default::default()
+ };
assert!(!FFIPersister::new(cb).persists_durably());
}
@@ -5324,11 +5304,13 @@ mod tests {
/// attests durability.
#[test]
fn fully_wired_persister_attests_durability() {
- let mut cb = PersistenceCallbacks::default();
- cb.on_changeset_begin_fn = Some(noop_begin);
- cb.on_changeset_end_fn = Some(noop_end);
- cb.on_persist_account_address_pools_fn = Some(noop_pools);
- cb.on_persist_invitations_fn = Some(noop_invitations);
+ let cb = PersistenceCallbacks {
+ on_changeset_begin_fn: Some(noop_begin),
+ on_changeset_end_fn: Some(noop_end),
+ on_persist_account_address_pools_fn: Some(noop_pools),
+ on_persist_invitations_fn: Some(noop_invitations),
+ ..Default::default()
+ };
assert!(FFIPersister::new(cb).persists_durably());
}
@@ -5385,10 +5367,12 @@ mod tests {
overlap: std::sync::atomic::AtomicBool::new(false),
}));
- let mut cb = PersistenceCallbacks::default();
- cb.context = probe as *const RoundProbe as *mut c_void;
- cb.on_changeset_begin_fn = Some(probing_begin);
- cb.on_changeset_end_fn = Some(probing_end);
+ let cb = PersistenceCallbacks {
+ context: probe as *const RoundProbe as *mut c_void,
+ on_changeset_begin_fn: Some(probing_begin),
+ on_changeset_end_fn: Some(probing_end),
+ ..Default::default()
+ };
let persister = std::sync::Arc::new(FFIPersister::new(cb));
let wallet_id: WalletId = [0x42u8; 32];
@@ -5713,7 +5697,7 @@ mod tests {
assert!(
restored
.highest_generated
- .map_or(false, |h| h >= RESTORED_INDEX),
+ .is_some_and(|h| h >= RESTORED_INDEX),
"highest_generated must advance past the pre-derived gap window"
);
@@ -5892,7 +5876,7 @@ mod tests {
const ECDSA_IDX: u32 = 502;
for idx in [BLS_IDX, EDDSA_IDX, ECDSA_IDX] {
assert!(
- pool.addresses.get(&idx).is_none(),
+ !pool.addresses.contains_key(&idx),
"index {idx} must start with no pre-seeded entry"
);
}
diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md
index abe9c265b6..81fb35b564 100644
--- a/packages/rs-platform-wallet-storage/SCHEMA.md
+++ b/packages/rs-platform-wallet-storage/SCHEMA.md
@@ -37,7 +37,7 @@ Any `meta_*` row whose parent object does not exist — because it was never cre
A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives.
-The 21 tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section.
+The tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. Diagrams and the [Tables](#tables) section below cover V001; `core_address_pool`, `meta_data_versions`, and `meta_store_generation` (V003), plus `invitations` (V004), are not yet documented here — see the [Migrations](#migrations) log for what they add in the meantime.
## Diagram 1 — Core / L1 (Bitcoin/Dash layer)
@@ -60,9 +60,12 @@ erDiagram
ACCOUNT_REGISTRATIONS {
BLOB wallet_id PK
- TEXT account_type PK "standard_bip44 | standard_bip32 | coinjoin | identity_registration | ..."
+ TEXT account_type PK "standard_bip44 | ... | provider_operator | provider_platform"
INTEGER account_index PK
- BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry"
+ INTEGER key_class PK "discriminator; sentinel 0 unless PlatformPayment"
+ BLOB user_identity_id PK "discriminator; sentinel zeroblob(32) unless DashPay"
+ BLOB friend_identity_id PK "discriminator; sentinel zeroblob(32) unless DashPay"
+ BLOB account_xpub_bytes "bincode: AccountRegistrationEntry, or ProviderKeyAccountEntry for provider_* types"
}
CORE_TRANSACTIONS {
@@ -338,11 +341,19 @@ direct children; identity-owned children cascade through `identities`.
### `account_registrations`
One row per account registered on a wallet (xpub + account type + index).
-The `account_xpub_bytes` blob carries the full `AccountRegistrationEntry`;
-the typed `account_type` / `account_index` columns mirror it for SQL
-lookups without blob decoding.
-
-- PK: `(wallet_id, account_type, account_index)`.
+The `account_xpub_bytes` blob carries the full `AccountRegistrationEntry`
+for secp256k1 accounts, or a `ProviderKeyAccountEntry` for the two
+provider key-material account types (`'provider_operator'` BLS,
+`'provider_platform'` EdDSA — index-less, always `account_index = 0`); the
+typed `account_type` / `account_index` columns mirror the common fields for
+SQL lookups without blob decoding.
+
+- PK: `(wallet_id, account_type, account_index, key_class, user_identity_id,
+ friend_identity_id)` — the last three columns discriminate accounts that
+ otherwise share `(account_type, account_index)`: PlatformPayment's
+ `key_class` axis, and the DashPay `(user_identity_id, friend_identity_id)`
+ pair. Sentinel `0` / `zeroblob(32)` default for account types without
+ that axis (including the provider key-material types).
- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
### `core_transactions`
@@ -654,3 +665,6 @@ having to grep this repo.
| Version | File | Description |
|---|---|---|
| V001 | `V001__initial.rs` | Full schema: all 21 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) |
+| V002 | `V002__address_height_pin.rs` | Adds `platform_addresses.as_of_height` (the Platform-block-height pin reconciling proof-attested balances against the delta stream; `DEFAULT 0` = unknown provenance for pre-existing rows). Additive column, no new table. |
+| V003 | `V003__unified.rs` | Adds `core_address_pool` (per-index address-pool rows replacing `core_utxos` script-derivation for the address-reuse guard), `meta_data_versions` (per-`(wallet_id, domain)` cache-invalidation `seq`), and `meta_store_generation` (single-row store-generation token). Additive only. |
+| V004 | `V004__invitations.rs` | Adds `invitations` for DIP-13 DashPay invitation lifecycle records, keyed by wallet and outpoint. |
diff --git a/packages/rs-platform-wallet-storage/migrations/V005__pool_public_key.rs b/packages/rs-platform-wallet-storage/migrations/V005__pool_public_key.rs
new file mode 100644
index 0000000000..90df5523c2
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/migrations/V005__pool_public_key.rs
@@ -0,0 +1,13 @@
+//! Persist typed public keys carried by address-pool rows (dashpay/platform#4113).
+//!
+//! This closes the gap where pre-derived platform-node Ed25519 keys were silently
+//! dropped during SQLite round-trips. SLIP-10 supports hardened derivation only,
+//! so a watch-only account xpub cannot regenerate them. Nullable key bytes and a
+//! curve discriminator preserve typed entries; existing rows and snapshots whose
+//! `AddressInfo` has no public key remain NULL in both columns.
+
+pub fn migration() -> String {
+ "ALTER TABLE core_address_pool ADD COLUMN public_key BLOB NULL;
+ALTER TABLE core_address_pool ADD COLUMN key_type INTEGER NULL CHECK (key_type IS NULL OR key_type IN (0, 1, 2));"
+ .to_string()
+}
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs
index 5281cdfb6a..cb78b8cd69 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs
@@ -227,6 +227,39 @@ pub enum WalletStorageError {
)]
AccountRegistrationEntryMismatch,
+ /// A provider key-material entry uses an incompatible account-registration
+ /// path, pairs an account type with the wrong key curve, or has persisted
+ /// typed columns that contradict its decoded `ProviderKeyAccountEntry`.
+ /// The guards reject it on write or decode so cross-curve-confused data
+ /// never enters a wallet.
+ #[error(
+ "provider key-material account was submitted through an incompatible \
+ account-registration path, paired with the wrong key curve, or has \
+ corrupted persisted data"
+ )]
+ ProviderKeyAccountEntryMismatch,
+
+ /// An incoming provider key-material entry conflicts with another entry in
+ /// the flush or with the account already persisted under the same label.
+ /// The store cannot tell which extended public key is correct, so it fails
+ /// closed instead of letting write order pick a winner.
+ #[error(
+ "conflicting provider key account for {account_type} \
+ (extended public keys differ)"
+ )]
+ ProviderKeyAccountConflict { account_type: &'static str },
+
+ /// An incoming typed address-pool row conflicts with key material already
+ /// persisted at the same account, pool, and address index.
+ #[error(
+ "conflicting typed pool key for {account_type} at address index {address_index} \
+ (public key or key type differs)"
+ )]
+ TypedPoolKeyConflict {
+ account_type: &'static str,
+ address_index: u32,
+ },
+
/// Account was rejected by the wallet manager (e.g. `account_type` is unknown, or
/// `account_index` is out of range). The `cause` is a static string describing the reason.
#[error("account rejected by wallet manager: {cause}")]
@@ -435,6 +468,9 @@ impl WalletStorageError {
| Self::IdentityEntryIdMismatch
| Self::OrphanedIdentityEntry { .. }
| Self::AccountRegistrationEntryMismatch
+ | Self::ProviderKeyAccountEntryMismatch
+ | Self::ProviderKeyAccountConflict { .. }
+ | Self::TypedPoolKeyConflict { .. }
| Self::AccountRecordInvalid { .. }
| Self::MissingAccount { .. }
| Self::AccountRejected { .. }
@@ -522,6 +558,9 @@ impl WalletStorageError {
Self::MissingAccount { .. } => "missing_account_registration_entry",
Self::AccountRejected { .. } => "account_rejected",
Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch",
+ Self::ProviderKeyAccountEntryMismatch => "provider_key_account_entry_mismatch",
+ Self::ProviderKeyAccountConflict { .. } => "provider_key_account_conflict",
+ Self::TypedPoolKeyConflict { .. } => "typed_pool_key_conflict",
Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch",
Self::BlobTooLarge { .. } => "blob_too_large",
Self::IntegerOverflow { .. } => "integer_overflow",
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
index d21f3e8a85..695a1af3e6 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
@@ -19,7 +19,9 @@ use crate::sqlite::reports::{CommitReport, DeleteWalletReport};
use crate::sqlite::schema;
use crate::sqlite::util::permissions::{apply_secure_permissions, precreate_secure};
use crate::sqlite::util::safe_cast;
-use crate::sqlite::util::wallet::{apply_persisted_core_state, build_wallet};
+use crate::sqlite::util::wallet::{
+ apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool,
+};
/// Persisted-but-not-rehydrated areas, surfaced in the structured
/// `tracing::info!` summary on every `load()`.
@@ -882,8 +884,8 @@ impl PlatformWalletPersistence for SqlitePersister {
/// payload (network, birth height, account manifest, core state,
/// identities, `Consumed`-filtered asset locks). Carries **no** `Wallet`
/// or key material — the manager rebuilds each wallet watch-only and
- /// signs later on demand. The `tracing::info!` summary reports
- /// `wallets_rehydrated`.
+ /// signs later on demand.
+ /// The `tracing::info!` summary reports `wallets_rehydrated`.
///
/// Fail-hard: any row that fails to decode (or has a malformed
/// `wallet_id`) aborts the whole load — corruption is never skipped.
@@ -1054,7 +1056,7 @@ impl PlatformWalletPersistence for SqlitePersister {
// this directly — the old skeleton + core_state replay fallback is
// gone.
let wallet = if account_manifest.is_empty() {
- // No core (spending) accounts for this wallet. An empty manifest
+ // No accounts of any kind for this wallet. An empty manifest
// is NOT necessarily an orphaned row: a platform-only wallet — a
// Platform identity plus contacts, with no core accounts —
// legitimately has one. Register it as an external-signable
@@ -1091,9 +1093,12 @@ impl PlatformWalletPersistence for SqlitePersister {
&wallet,
birth_height,
);
+ // Provider key-material accounts hold no funds, so only the ECDSA
+ // half feeds the UTXO/balance projection here. The platform-node
+ // pre-derived-key pool is restored separately below.
apply_persisted_core_state(
&mut wallet_info,
- &account_manifest,
+ &account_manifest.ecdsa,
&core_state,
&utxo_accounts,
&used_core_addresses,
@@ -1104,6 +1109,17 @@ impl PlatformWalletPersistence for SqlitePersister {
hex::encode(wallet_id)
))
})?;
+ if account_manifest.provider.iter().any(|entry| {
+ entry.account_type == key_wallet::account::AccountType::ProviderPlatformKeys
+ }) {
+ restore_provider_platform_node_pool(&mut wallet_info, &conn, &wallet_id, network)
+ .map_err(|e| {
+ PersistenceError::backend(format!(
+ "platform-node pool rehydration failed for {}: {e}",
+ hex::encode(wallet_id)
+ ))
+ })?;
+ }
state.wallets.insert(
wallet_id,
@@ -1224,6 +1240,13 @@ fn apply_changeset_to_tx(
if !cs.account_registrations.is_empty() {
schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?;
}
+ if !cs.provider_key_account_registrations.is_empty() {
+ schema::accounts::apply_provider_registrations(
+ tx,
+ wallet_id,
+ &cs.provider_key_account_registrations,
+ )?;
+ }
// Pools land before core so the UTXO writer can attribute each outpoint
// to its owning account by matching the outpoint's script against a
// freshly-written `core_address_pool` row.
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
index 6eb4ee2ea6..7863b5ba80 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
@@ -1,12 +1,16 @@
//! `account_registrations` writer + keyless reader (platform-payment
-//! registrations and the rehydration account-manifest oracle).
+//! registrations and the rehydration account-manifest oracle), including
+//! provider key-material accounts.
use std::collections::BTreeMap;
+use key_wallet::account::AccountType;
use key_wallet::bip32::ExtendedPubKey;
use rusqlite::{params, Connection, Transaction};
-use platform_wallet::changeset::AccountRegistrationEntry;
+use platform_wallet::changeset::{
+ AccountRegistrationEntry, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey,
+};
use platform_wallet::wallet::platform_wallet::WalletId;
use crate::sqlite::error::WalletStorageError;
@@ -17,6 +21,30 @@ use crate::sqlite::schema::blob::impl_persistable_blob;
// the `account_xpub_bytes` blob column.
impl_persistable_blob!(AccountRegistrationEntry);
+// PUBLIC material only: the provider account's own-curve extended PUBLIC key
+// (BLS operator / EdDSA platform node) reaching the same blob column. The type
+// has no field that could carry signing material.
+impl_persistable_blob!(ProviderKeyAccountEntry);
+
+/// The persisted account manifest of one wallet: the secp256k1 accounts and
+/// the provider key-material accounts, which carry a non-secp256k1 extended
+/// public key and so cannot share one entry type.
+#[derive(Debug, Clone, Default)]
+pub struct AccountManifest {
+ /// secp256k1 accounts, ordered by `(account_type, account_index)`.
+ pub ecdsa: Vec,
+ /// BLS operator-key / EdDSA platform-node-key accounts, ordered by
+ /// `account_type`.
+ pub provider: Vec,
+}
+
+impl AccountManifest {
+ /// True when the wallet registered no account of either kind.
+ pub fn is_empty(&self) -> bool {
+ self.ecdsa.is_empty() && self.provider.is_empty()
+ }
+}
+
/// Decoded `platform_payment` account registration: the DIP-17 account
/// index and its extended public key, recovered from the bincode-serde
/// `AccountRegistrationEntry` stored in `account_xpub_bytes`.
@@ -117,6 +145,14 @@ pub(crate) fn all_platform_payment_registrations(
Ok(out)
}
+/// Persist ordinary secp256k1 account registrations for one wallet.
+///
+/// # Errors
+///
+/// Returns [`WalletStorageError::ProviderKeyAccountEntryMismatch`] if a
+/// provider key-material account is submitted through this ECDSA writer.
+/// Returns another [`WalletStorageError`] if an entry cannot be encoded or the
+/// database write fails.
pub fn apply_registrations(
tx: &Transaction<'_>,
wallet_id: &WalletId,
@@ -125,47 +161,289 @@ pub fn apply_registrations(
if entries.is_empty() {
return Ok(());
}
- // `account_xpub_bytes` holds the encoded `AccountRegistrationEntry`; the
- // separate typed columns mirror it for SQL. `key_class` and the DashPay
- // `(user, friend)` identity pair widen the PK so distinct accounts that
- // share `(account_type, account_index)` don't overwrite each other.
- let mut stmt = tx.prepare_cached(
- "INSERT INTO account_registrations \
- (wallet_id, account_type, account_index, key_class, \
- user_identity_id, friend_identity_id, account_xpub_bytes) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
- ON CONFLICT(wallet_id, account_type, account_index, key_class, \
- user_identity_id, friend_identity_id) DO UPDATE SET \
- account_xpub_bytes = excluded.account_xpub_bytes",
- )?;
+ if entries
+ .iter()
+ .any(|entry| is_provider_key_material(&entry.account_type))
+ {
+ return Err(WalletStorageError::ProviderKeyAccountEntryMismatch);
+ }
+ let mut stmt = tx.prepare_cached(UPSERT_ACCOUNT_SQL)?;
+ for entry in entries {
+ upsert_account_row(
+ &mut stmt,
+ wallet_id,
+ &entry.account_type,
+ blob::encode(entry)?,
+ )?;
+ }
+ Ok(())
+}
+
+fn is_provider_key_material(account_type: &AccountType) -> bool {
+ match account_type {
+ AccountType::ProviderOperatorKeys => true,
+ AccountType::ProviderPlatformKeys => true,
+ AccountType::Standard { .. } => false,
+ AccountType::CoinJoin { .. } => false,
+ AccountType::IdentityRegistration => false,
+ AccountType::IdentityTopUp { .. } => false,
+ AccountType::IdentityTopUpNotBoundToIdentity => false,
+ AccountType::IdentityInvitation => false,
+ AccountType::AssetLockAddressTopUp => false,
+ AccountType::AssetLockShieldedAddressTopUp => false,
+ AccountType::ProviderVotingKeys => false,
+ AccountType::ProviderOwnerKeys => false,
+ AccountType::DashpayReceivingFunds { .. } => false,
+ AccountType::DashpayExternalAccount { .. } => false,
+ AccountType::PlatformPayment { .. } => false,
+ }
+}
+
+/// Upsert for an ordinary secp256k1 `account_registrations` row.
+const UPSERT_ACCOUNT_SQL: &str = "INSERT INTO account_registrations \
+ (wallet_id, account_type, account_index, key_class, \
+ user_identity_id, friend_identity_id, account_xpub_bytes) \
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
+ ON CONFLICT(wallet_id, account_type, account_index, key_class, \
+ user_identity_id, friend_identity_id) DO UPDATE SET \
+ account_xpub_bytes = excluded.account_xpub_bytes";
+
+/// Insert a provider key-material account without overwriting persisted bytes.
+const UPSERT_PROVIDER_ACCOUNT_SQL: &str = "INSERT INTO account_registrations \
+ (wallet_id, account_type, account_index, key_class, \
+ user_identity_id, friend_identity_id, account_xpub_bytes) \
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
+ ON CONFLICT(wallet_id, account_type, account_index, key_class, \
+ user_identity_id, friend_identity_id) DO NOTHING";
+
+/// Bind and execute an account-registration statement. The typed PK columns
+/// derive from `account_type` here so writer and reader cross-checks stay aligned.
+fn upsert_account_row(
+ stmt: &mut rusqlite::CachedStatement<'_>,
+ wallet_id: &WalletId,
+ account_type: &AccountType,
+ payload: Vec,
+) -> Result<(), WalletStorageError> {
+ let (user_identity_id, friend_identity_id) = account_dashpay_ids(account_type);
+ stmt.execute(params![
+ wallet_id.as_slice(),
+ account_type_db_label(account_type),
+ i64::from(account_index(account_type)),
+ i64::from(account_key_class(account_type)),
+ &user_identity_id[..],
+ &friend_identity_id[..],
+ payload,
+ ])?;
+ Ok(())
+}
+
+/// Persist provider key-material accounts into `account_registrations`.
+///
+/// The account row uses the same PK as [`apply_registrations`]: a provider
+/// account is index-less, so `(wallet_id, account_type)` plus the sentinel
+/// columns is its natural key. Re-persisting an identical row is a no-op.
+///
+/// # Errors
+///
+/// [`WalletStorageError::ProviderKeyAccountEntryMismatch`] if an entry pairs an
+/// `account_type` with the wrong curve — the same invariant the reader enforces,
+/// checked here so a mis-paired entry cannot upsert onto (and destroy) the
+/// ECDSA account sharing this table's PK space.
+///
+/// [`WalletStorageError::ProviderKeyAccountConflict`] if two entries in one call
+/// claim the same account with **different** extended public keys, or if an
+/// incoming key differs from the one already persisted for that account.
+pub fn apply_provider_registrations(
+ tx: &Transaction<'_>,
+ wallet_id: &WalletId,
+ entries: &[ProviderKeyAccountEntry],
+) -> Result<(), WalletStorageError> {
+ if entries.is_empty() {
+ return Ok(());
+ }
+ // Validate the batch before write SQL runs so rejection cannot leave a
+ // sibling half-applied independently of the caller's transaction discipline.
+ let mut encoded: Vec<(&ProviderKeyAccountEntry, &'static str, Vec)> =
+ Vec::with_capacity(entries.len());
for entry in entries {
- let account_type = account_type_db_label(&entry.account_type);
- let account_index = account_index(&entry.account_type);
- let key_class = account_key_class(&entry.account_type);
- let (user_identity_id, friend_identity_id) = account_dashpay_ids(&entry.account_type);
- let payload = blob::encode(entry)?;
- stmt.execute(params![
- wallet_id.as_slice(),
- account_type,
- i64::from(account_index),
- i64::from(key_class),
- &user_identity_id[..],
- &friend_identity_id[..],
- payload,
- ])?;
+ if !provider_curve_matches_type(&entry.account_type, &entry.extended_public_key) {
+ return Err(WalletStorageError::ProviderKeyAccountEntryMismatch);
+ }
+ let label = account_type_db_label(&entry.account_type);
+ let payload = blob::encode(&ProviderKeyAccountEntry {
+ account_type: entry.account_type,
+ extended_public_key: entry.extended_public_key.clone(),
+ })?;
+ // Two entries for one account are expected — `Merge` is append-only, so
+ // a re-emitted registration can ride the same flush. Identical ones are
+ // equivalent. Two that disagree about the account's own xpub are a
+ // contradiction no merge semantic can resolve — one of them is wrong
+ // and we cannot tell which, so fail closed rather than let write order
+ // decide.
+ if let Some((_, _, prior)) = encoded.iter().find(|(_, l, _)| *l == label) {
+ if prior != &payload {
+ return Err(WalletStorageError::ProviderKeyAccountConflict {
+ account_type: label,
+ });
+ }
+ }
+ encoded.push((entry, label, payload));
+ }
+
+ // Same-label payloads are byte-identical here, so either surviving index
+ // is equivalent.
+ let distinct_accounts: BTreeMap<&'static str, usize> = encoded
+ .iter()
+ .enumerate()
+ .map(|(index, (_, label, _))| (*label, index))
+ .collect();
+ for (&label, &index) in &distinct_accounts {
+ let (entry, _, payload) = &encoded[index];
+ let conflicts = load_provider_account_payload(tx, wallet_id, &entry.account_type)?
+ .is_some_and(|stored| stored.as_slice() != payload.as_slice());
+ if conflicts {
+ return Err(WalletStorageError::ProviderKeyAccountConflict {
+ account_type: label,
+ });
+ }
+ }
+
+ // The conflict checks and `DO NOTHING` jointly prevent account key
+ // material from being overwritten.
+ let mut account_stmt = tx.prepare_cached(UPSERT_PROVIDER_ACCOUNT_SQL)?;
+ for (entry, _, payload) in encoded {
+ upsert_account_row(&mut account_stmt, wallet_id, &entry.account_type, payload)?;
}
Ok(())
}
+/// Return the encoded parent account payload for one provider account.
+fn load_provider_account_payload(
+ conn: &Connection,
+ wallet_id: &WalletId,
+ account_type: &AccountType,
+) -> Result