fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113)#4117
Conversation
…ey persistence Phase 1c spec for persisting provider_key_account_registrations (BLS operator-key / EdDSA platform-node-key accounts) instead of dropping them in versions.rs. Covers round-trip, discriminated BLS/EdDSA encoding, one-to-many platform-node keys, empty case, migration/schema-freeze correctness, fail-hard trust boundary, no-private-key-material invariant, and cross-backend parity with the FFI persister.
The persisted shape of a `ProviderKeyAccountEntry` minus its `derived_platform_node_keys`: an unbounded one-to-many belongs in its own rows, not inline in an account's payload. `account_type` stays on the blob so a backend can cross-check its typed column against the decoded payload. Refs: #4113 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`store()` dropped `provider_key_account_registrations` on the floor, so a reloaded seedless wallet came back without its BLS operator-key and EdDSA platform-node-key accounts. The EdDSA platform-node pool is hardened-only (SLIP-10, no public derivation), so those pre-derived keys were unrecoverable without the recovery phrase — exactly the loss the batch was captured to prevent. Both provider accounts now ride the existing `account_registrations` row (its `account_type` CHECK already admits both labels), encoded as a `ProviderKeyRegistrationBlob`; `account_type` is the decode discriminator, matching the convention the FFI backend already ships. V004 adds the one table that has nowhere else to live: `provider_platform_node_keys`, the one-to-many node-key batch, FK'd to its parent account row so it cannot outlive it. V001-V003 are byte-identical. Reader hardening: the ECDSA `SELECT` now excludes the provider rows (their blob is over a different curve and would hard-error the decode), and a provider row is rejected when its typed columns contradict the blob or the blob carries the wrong curve for its account type (`ProviderKeyAccountEntryMismatch`). Tests: `sqlite_provider_key_accounts.rs` (12 cases: round-trip, per-curve decode, cross-curve rejection, node-key order/completeness, empty case, idempotent re-persist, batch replacement, corrupt/oversize blob, cascade) and `sqlite_v004_migration.rs` (additivity, pre-V004 rows survive). Migration-version assertions now derive from the embedded set instead of a hardcoded 3, so they don't rot on the next migration. Closes: #4113 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n table Migration log stopped at V001; V002 (ADDR-09 height pin), V003 (#3968 unified migration), and V004 (#4113 provider key accounts) were never added. Also documents the new provider_platform_node_keys child table and corrects account_registrations' PK, which SCHEMA.md listed as 3 columns while V001 always declared 6 (key_class + DashPay pair). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Independently ran cargo test/clippy on platform-wallet-storage, constructed and verified an adversarial duplicate-account-type changeset (silent node-key-batch data loss, QA-001), and audited testspec-4113.md coverage gaps (shrink-direction re-persist, node_id/key_index edge cases untested).
…keys `apply_provider_registrations` cleared a provider account's node-key rows before re-inserting the incoming batch. The pool is hardened-only (Ed25519/SLIP-10), so any key the store forgets is one no watch-only wallet can ever re-derive — and two live callers hand it a batch that is shorter than what is already persisted: - registration falls back to an empty batch when pre-derivation fails (`wallet_lifecycle::register_wallet` treats that as non-fatal); - `Merge` is append-only `.extend()`, so one flush can carry two entries for the same account, and the second one wins. Node keys are now upserted per `key_index` and never deleted: a shorter or empty batch says nothing about the missing indices rather than retracting them. Guarding the DELETE on a non-empty batch would have fixed only the first path — the merged-entry case passes a non-empty batch. Three regression tests, each confirmed failing against the previous writer: shrinking batch, empty batch, and two merged entries for one account. Also in this commit: - Extract `rebuild_provider_key_account` into `platform-wallet`; the SQLite and FFI restore paths were two verbatim copies of the same watch-only rebuild (same constructors, same inserters, differing only in error type). Both now call it and map the one error into their own. - Narrow `ProviderKeyRegistrationBlob`'s rustdoc: it is the SQLite payload shape, not a cross-backend wire contract. The FFI bincodes the bare key; what the backends share is the `account_type` discriminator, nothing more. - Trim the V004 header and two over-long doc comments to the internal cap. Refs: #4113 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two writers share `account_registrations`: one PK space, one blob column, discriminated only by `account_type`. The reader validated the curve/type pairing; the writer trusted its caller. A `ProviderOperatorKeys` entry carrying an EdDSA key — reachable through the public `store()` and a `pub` changeset field — would upsert onto the operator account's row with a payload the fail-hard reader then rejects, making the whole wallet unopenable on the next `load()`. The writer now enforces the same invariant, before any SQL runs, so a mis-paired entry is refused instead of stored as a landmine. Duplicate entries for one account in a single flush are now decided, not left to write order. `Merge` is append-only, so a re-emitted registration can ride the same flush as the original; identical entries reconcile (node keys union by index — nothing is lost). Two entries that disagree about the account's own extended public key are a contradiction no merge semantic can resolve: one is wrong and the store cannot tell which, so both are refused (`ProviderKeyAccountConflict`) rather than letting the last write win. Union, not rejection, is the answer for the node-key batches themselves: they are hardened-only (Ed25519/SLIP-10) and unrecoverable, every key in either batch is a legitimate key of the same account, and erroring would fail the whole flush — including the unrelated sub-changesets riding it — over an anomaly with a lossless reading. Tests (both guards confirmed failing against c1349e6 first): mis-paired curve rejected at write time with no row left behind; conflicting duplicates rejected with neither written. Plus two coverage tests, no bug behind either: provider rows + node keys + domain-seq bump all roll back together on a mid-flush failure (mirroring tc_b_012), and a registered account with an empty pre-derived batch round-trips as an account with zero keys, not as no account. Also documents the cross-backend discriminator parity at the match itself rather than only in a commit message. Refs: #4113 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…flict fix Final-pass QA against 1df6169: re-attempts the original duplicate-entry repro (now unions, confirmed) plus three new adversarial shapes against ProviderKeyAccountConflict: a three-way duplicate, a reordered-but-equal node-key duplicate (confirmed not a conflict), and a same-index/ conflicting-value node-key collision within one store() call. The last one surfaces a real asymmetry: the account-level conflict check compares only the encoded (account_type, extended_public_key) payload, so two entries sharing that payload but disagreeing on a node key's bytes at the same index reach the child-table's ON CONFLICT ... DO UPDATE with no arbitration -- silent last-write-wins, unlike the fail-closed reasoning applied one level up. Reported as a finding, not fixed here (test documents current behavior and flags it for a design decision). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… index The account-level conflict check compared only the encoded (account_type, xpub) payload, so two entries agreeing on the account but carrying different bytes at the SAME key_index reached `ON CONFLICT ... DO UPDATE` and silently overwrote one with the other — no error, no diagnostic, on material nothing can re-derive. Found independently by Smythe (SEC-007) and Marvin (QA-005). The asymmetry was the real defect: fail closed on a contradictory xpub one level up, silently pick a winner one level down. A node key is fully determined by its account xpub and index — derivation is a pure function and `node_id` is hash160(public_key) — so two different values at one index mean one is wrong and the store cannot tell which. Same contradiction, same answer: refuse the flush (`ProviderNodeKeyConflict`), writing neither key. Checked in both directions: between entries within a flush, and against the row already stored — so a stale or corrupted re-registration cannot overwrite a good key that a later flush disagrees with. The SQL drops to `DO NOTHING`, which the checks make a no-op for identical bytes and which, if they were ever bypassed, still cannot destroy a derived key. Marvin's `marvin_adversarial_same_index_conflicting_node_key_value_is_silently_ overwritten` documented the behavior rather than asserting it; it is now `sec_007_same_index_conflicting_node_keys_are_rejected`, asserting rejection and that neither key lands. A second test covers the cross-flush case his in-batch probe could not reach. Both confirmed failing against 33d18ac. Refs: #4113 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 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 |
|
⛔ Blockers found — Sonnet deferred (commit 3c26fc1) |
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Provider account xpubs round-trip and the targeted 24-test persistence suite passes, but the persisted hardened node-key batch is discarded from the public SQLite load result. The writer also fails to reject xpub conflicts against an existing provider account, leaving two blocking persistence defects in the new behavior.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (failed),gpt-5.6-sol— general (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 2 blocking
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs:50-57: Expose persisted platform-node keys after SQLite load
`load_provider_state` reconstructs each provider entry with its `derived_platform_node_keys`, but this loop consumes only `account_type` and `extended_public_key`. The manifest is then dropped, `ClientWalletStartState` has no field for the derived batch, and the schema reader is private in production. The restored EdDSA account contains only its watch-only account xpub, which cannot derive hardened SLIP-10 children without the seed, so `SqlitePersister::load()` makes the newly persisted node-key rows unreachable. Swift has a separate host-side persistence path for these keys, but SQLite exposes no equivalent side channel. This defeats the PR's stated seedless recovery goal, and the end-to-end test misses it because it asserts only the restored account xpubs.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:249-255: Reject xpub conflicts against the persisted provider account
This conflict check compares xpub payloads only within the current `store()` batch. On a later store, `UPSERT_ACCOUNT_SQL` replaces the existing account's `account_xpub_bytes` without comparing it to the persisted value. The cross-flush node-key guard does not close this gap: BLS entries never carry child rows, while an EdDSA entry with an empty or disjoint batch avoids overlapping-index checks and leaves append-only children derived from the old xpub attached to the new parent. Load then returns a silently replaced BLS account or an internally inconsistent EdDSA snapshot. Compare the incoming payload with the existing parent row before performing either write, using the same fail-closed policy already applied to in-batch xpub conflicts and persisted node-key conflicts.
…ub overwrites Two blocking review findings on #4117: SqlitePersister::load() dropped persisted hardened platform-node keys on the floor (no production accessor existed), and a second store() for an already-persisted provider account silently overwrote its xpub with no fail-closed guard. Add SqlitePersister::list_provider_node_keys() to surface the persisted batch, and reject a persisted-xpub mismatch (plus a provider-label bypass through the plain ECDSA writer) with the existing ProviderKeyAccountConflict/ProviderKeyAccountEntryMismatch policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…tence trait grumpy-review follow-up on the prior commit. Bug 1's fix (list_provider_node_keys) was an inherent SqlitePersister method with no production caller — every real consumer goes through PlatformWalletPersistence, so the data was still practically unreachable. Move it to a default trait method (mirroring get_core_tx_record), wire a public WalletPersister::provider_node_keys() wrapper, and prove reachability via PlatformWallet::persister() with a new manager-layer integration test. Also: harden the provider parent-account upsert to ON CONFLICT DO NOTHING (it was DO UPDATE while sibling node-key rows were already DO NOTHING), replace the ad-hoc provider-type matches!() with an exhaustive match so a future AccountType variant can't silently bypass the ECDSA-writer guard, update stale/incomplete doc comments and error messages, and add regression tests for the zero-state and mixed-batch-atomicity cases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
Second grumpy-review follow-up on this PR. Three independent reviewers converged on the same gap: FFIPersister inherits the new provider_node_keys trait default (Ok(empty)) even though it genuinely forwards derived_platform_node_keys to the Swift host on store() — so an FFI-backed wallet silently reported "no keys" instead of surfacing that this backend can't read them back yet. Override the method to return an explicit error instead; a full Swift-side read-back callback is a separate, larger follow-up (out of scope here). Also: reworded ProviderKeyAccountEntryMismatch's Display message to drop internal "ECDSA writer" jargon, and renamed a test whose name implied transactional rollback when it actually exercises upfront batch validation (the assertions were already correct). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…ydration' into pr4117-codex-fix # Conflicts: # packages/rs-platform-wallet-ffi/src/persistence.rs # packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs # packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs # packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs # packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs # packages/rs-platform-wallet/src/changeset/mod.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
At exact head b7f3af9, carried-forward prior-1 remains a blocking SQLite rehydration defect: persisted platform-node public keys do not reach the restored managed pool. Prior-2 is fixed by the persisted parent-payload conflict check at accounts.rs:302-308. No new finding was identified in the latest reviewed-head delta.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (failed),gpt-5.6-sol— general (failed),gpt-5.6-sol— general (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
…ss pool rows PR #4127 replaced the retired dedicated-batch mechanism for platform-node Ed25519 keys with the generic account_address_pools pipeline, but only wired it up for the FFI backend. The SQLite backend silently dropped AddressInfo::public_key on every store()/load() round trip, so ProviderPlatformKeys pools came back empty after a restart -- these keys are SLIP-10 hardened-only and cannot be re-derived from a watch-only xpub. Adds migration V005 (nullable public_key/key_type columns on core_address_pool), persists the typed key alongside each pool row, and restores AbsentHardened platform-node entries during load() by reconstructing their AddressInfo the same way populate_platform_node_pool does at registration time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cKDZc6zhCkstdqE1HcSuU
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cKDZc6zhCkstdqE1HcSuU
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
Carried-Forward Prior Findings: none; both prior blockers are fixed. New Findings In Latest Delta: one blocking writer-validation defect and one corruption-validation suggestion remain. Static inspection and git diff --check passed; the targeted test could not run offline because the pinned rust-dashcore revision is unavailable locally.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol/high— general (failed),gpt-5.6-sol/high— security-auditor (failed),gpt-5.6-sol/high— rust-quality (failed),gpt-5.6-sol/high— ffi-engineer (failed),gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— rust-quality (failed),gpt-5.6-sol— ffi-engineer (completed),gpt-5.6-sol— rust-quality (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking | 🟡 1 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:87-95: Reject malformed typed-key widths before committing them
PublicKeyType variants contain unconstrained Vec<u8> values, but this writer persists them without enforcing the 33/32/48-byte widths that load_typed_pool_entries requires. A malformed ProviderPlatformKeys EdDSA entry therefore makes store() succeed and causes the next load() to fail, durably poisoning the wallet database. Validate the variant-specific width before executing the upsert.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:131-150: Do not filter malformed public-key rows out before validation
V005 permits public_key and key_type to be populated independently, but both queries select only key_type IS NOT NULL. A row containing key bytes with a NULL discriminator is silently omitted, contradicting the loader's fail-hard corruption policy and restoring an incomplete platform-node pool. Select rows where either typed column is present and reject mismatched nullability, or enforce paired nullability in the schema.
| let (public_key, key_type): (Option<&[u8]>, Option<i64>) = match info | ||
| .public_key | ||
| .as_ref() | ||
| { | ||
| None => (None, None), | ||
| Some(PublicKeyType::ECDSA(bytes)) => (Some(bytes.as_slice()), Some(KEY_TYPE_ECDSA)), | ||
| Some(PublicKeyType::EdDSA(bytes)) => (Some(bytes.as_slice()), Some(KEY_TYPE_EDDSA)), | ||
| Some(PublicKeyType::BLS(bytes)) => (Some(bytes.as_slice()), Some(KEY_TYPE_BLS)), | ||
| }; |
There was a problem hiding this comment.
🔴 Blocking: Reject malformed typed-key widths before committing them
PublicKeyType variants contain unconstrained Vec values, but this writer persists them without enforcing the 33/32/48-byte widths that load_typed_pool_entries requires. A malformed ProviderPlatformKeys EdDSA entry therefore makes store() succeed and causes the next load() to fail, durably poisoning the wallet database. Validate the variant-specific width before executing the upsert.
source: ['codex']
| let (max_script_len, max_public_key_len): (Option<i64>, Option<i64>) = conn.query_row( | ||
| "SELECT MAX(length(script)), MAX(length(public_key)) FROM core_address_pool \ | ||
| WHERE wallet_id = ?1 AND account_type = ?2 AND pool_type = ?3 \ | ||
| AND key_type IS NOT NULL", | ||
| params![wallet_id.as_slice(), account_type, pool_type], | ||
| |row| Ok((row.get(0)?, row.get(1)?)), | ||
| )?; | ||
| if let Some(len) = max_script_len { | ||
| blob::check_size(len)?; | ||
| } | ||
| if let Some(len) = max_public_key_len { | ||
| blob::check_size(len)?; | ||
| } | ||
|
|
||
| let mut stmt = conn.prepare( | ||
| "SELECT address_index, length(script), script, length(public_key), public_key, \ | ||
| key_type, used FROM core_address_pool \ | ||
| WHERE wallet_id = ?1 AND account_type = ?2 AND pool_type = ?3 \ | ||
| AND key_type IS NOT NULL \ | ||
| ORDER BY address_index", |
There was a problem hiding this comment.
🟡 Suggestion: Do not filter malformed public-key rows out before validation
V005 permits public_key and key_type to be populated independently, but both queries select only key_type IS NOT NULL. A row containing key bytes with a NULL discriminator is silently omitted, contradicting the loader's fail-hard corruption policy and restoring an incomplete platform-node pool. Select rows where either typed column is present and reject mismatched nullability, or enforce paired nullability in the schema.
source: ['codex']
The dedicated node-key-batch mechanism this review audited (V004__provider_key_accounts.rs, ProviderKeyRegistrationBlob, provider_platform_node_keys) was retired by commits b361e11/a536a26cba. The doc still made present-tense claims (e.g. "tests/sqlite_v004_migration.rs: 3/3 pass") about tests and code paths that no longer exist. Adds an explicit superseded banner so a reader doesn't mistake historical findings for current coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cKDZc6zhCkstdqE1HcSuU
UPSERT_POOL_SQL overwrote public_key/key_type unconditionally on conflict, unlike the adjacent monotonic `used = MAX(...)`. A second store() with a different key at the same (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, pool_type, address_index) silently clobbered the first with no error -- and these hardened-derivation keys cannot be re-derived from a watch-only xpub if lost. apply_pools() now pre-checks typed rows (key_type IS NOT NULL) against any existing row at the same key and rejects the whole flush via a new WalletStorageError::TypedPoolKeyConflict when the incoming public_key/key_type disagrees with what's already stored, mirroring ProviderKeyAccountConflict's existing fail-closed pattern for the sibling account-level xpub. Identical re-writes (idempotent resync) and fresh indices are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cKDZc6zhCkstdqE1HcSuU
populate_platform_node_pool (registration time) and restore_provider_platform_node_pool (SQLite load time) hand-duplicated the same 14-field AddressInfo construction and AbsentHardened pool insertion across a crate boundary, and had already drifted: one silently no-op'd when the account's hardened pool was missing, the other hard-errored. Extracts the shared logic into insert_platform_node_pool_entry, called from both sites. Unifies the missing-pool case to a hard error in both places -- a ProviderPlatformKeys account without its AbsentHardened pool is an invariant violation worth surfacing, not silently ignoring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cKDZc6zhCkstdqE1HcSuU
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
At exact head 3c26fc1, the two earlier blockers concerning SQLite key restoration and persisted provider-xpub conflicts are fixed. The two carried-forward core-pool findings remain valid, and the latest delta adds valid blockers for untyped key overwrites and incomplete used-index restoration. The targeted test could not run offline because the pinned rust-dashcore revision is unavailable locally.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (failed),gpt-5.6-sol— general (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking
2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:107-143: Reject untyped overwrites of persisted typed keys
New in the latest delta. The conflict query runs only when the incoming row has a typed key. If a later snapshot supplies `public_key: None` for an index that already contains a typed key, the guard is skipped and the upsert replaces both `public_key` and `key_type` with NULL. This irreversibly discards hardened Ed25519 platform-node material that cannot be regenerated from the account xpub, and the next load silently omits that row. Reject removal of an existing typed key while continuing to allow a typed key to upgrade a legacy NULL/NULL row.
In `packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs:301-325: Restore used platform-node entries into pool bookkeeping
New in the latest delta. SQLite passes the persisted `used` flag into this shared insertion helper, but the helper sets only `AddressInfo.used` and `highest_generated`; it leaves `used_at`, `pool.used_indices`, and `pool.highest_used` unset. The restored pool consequently reports the individual row as used while exposing `last_used_index = -1`, and key-wallet's `mark_used` and `mark_index_used` cannot repair the indexes because they update bookkeeping only during an unused-to-used transition. Mirror the FFI restore path by setting `used_at` and advancing `used_indices` and `highest_used` whenever `used` is true.
| @@ -85,12 +138,102 @@ pub fn apply_pools( | |||
| i64::from(info.index), | |||
| info.script_pubkey.as_bytes(), | |||
| info.used, | |||
| public_key, | |||
| key_type, | |||
| ])?; | |||
There was a problem hiding this comment.
🔴 Blocking: Reject untyped overwrites of persisted typed keys
New in the latest delta. The conflict query runs only when the incoming row has a typed key. If a later snapshot supplies public_key: None for an index that already contains a typed key, the guard is skipped and the upsert replaces both public_key and key_type with NULL. This irreversibly discards hardened Ed25519 platform-node material that cannot be regenerated from the account xpub, and the next load silently omits that row. Reject removal of an existing typed key while continuing to allow a typed key to upgrade a legacy NULL/NULL row.
source: ['codex']
| let info = AddressInfo { | ||
| address, | ||
| script_pubkey, | ||
| public_key: Some(PublicKeyType::EdDSA(public_key.to_vec())), | ||
| index, | ||
| path: key_wallet::bip32::DerivationPath::from(children), | ||
| used, | ||
| generated_at: 0, | ||
| used_at: None, | ||
| tx_count: 0, | ||
| total_received: 0, | ||
| total_sent: 0, | ||
| balance: 0, | ||
| label: None, | ||
| metadata: Default::default(), | ||
| }; | ||
|
|
||
| pool.address_index.insert(info.address.clone(), index); | ||
| pool.script_pubkey_index | ||
| .insert(info.script_pubkey.clone(), index); | ||
| pool.highest_generated = Some( | ||
| pool.highest_generated | ||
| .map_or(index, |highest| highest.max(index)), | ||
| ); | ||
| pool.addresses.insert(index, info); |
There was a problem hiding this comment.
🔴 Blocking: Restore used platform-node entries into pool bookkeeping
New in the latest delta. SQLite passes the persisted used flag into this shared insertion helper, but the helper sets only AddressInfo.used and highest_generated; it leaves used_at, pool.used_indices, and pool.highest_used unset. The restored pool consequently reports the individual row as used while exposing last_used_index = -1, and key-wallet's mark_used and mark_index_used cannot repair the indexes because they update bookkeeping only during an unused-to-used transition. Mirror the FFI restore path by setting used_at and advancing used_indices and highest_used whenever used is true.
source: ['codex']
Why this PR exists
rs-platform-wallet-storage's SQLite persister exhaustively destructuresPlatformWalletChangeSetbut only ever doeslet _ = provider_key_account_registrations;— BLS operator-key and EdDSA platform-node-key account registrations were silently dropped on everystore(). The FFI persistence backend already handled these; SQLite never did. Closes rs-platform-wallet-storage: persist provider_key_account_registrations (BLS/EdDSA provider-key accounts) #4113.V004child table,ProviderKeyRegistrationBlob,ProviderNodeKeyConflict) and replaced it with the genericaccount_address_poolspipeline already used for every other account type — but wired that replacement up for the FFI backend only. It never touchedrs-platform-wallet-storage, leaving a fresh, undiscovered gap: the SQLite backend's generic pool writer persistedscript_pubkey/usedper address-pool entry but silently droppedpublic_key.ProviderPlatformKeyspool comes back empty either way (old mechanism removed and never had SQLite coverage anyway, or new mechanism has the same gap) —provider_masternode_txs_blockingsilently returns zero platform-node-owned masternodes.feat/platform-wallet-storage-rehydration), which introduced the SQLite backend this PR extends.What was done
Provider key account registrations (original scope, unaffected by #4127)
apply_provider_registrations/ readerload_provider_statepersist BLS/EdDSA provider account xpubs viaProviderKeyAccountEntry, wired intotouched_domainsand the store/load round trip.build_walletrebuilds BLS/EdDSA watch-only provider accounts from persisted rows via the sharedrebuild_provider_key_accounthelper (used by both SQLite and FFI restore paths).Platform-node public key persistence (new — completes #4127's generic pipeline for SQLite)
V004__provider_key_accounts.rs,ProviderKeyRegistrationBlob,ProviderNodeKeyConflict, associated tests) — redundant with the generic pipeline feat(platform-wallet): persist typed BLS/EdDSA provider keys as core address rows #4127 introduced.V005__pool_public_key.rs— additivepublic_key BLOB NULL/key_type INTEGER NULLcolumns oncore_address_pool.apply_pools()(core_pool.rs) now persistsAddressInfo::public_keyalongside every pool row;load_typed_pool_entries()reads it back with fail-closed width/discriminant checks.restore_provider_platform_node_pool()(util/wallet.rs) rehydrates theProviderPlatformKeysaccount'sAbsentHardenedpool onload(), reconstructing eachAddressInfovia the same sharedinsert_platform_node_pool_entryhelper (rs-platform-wallet) thatpopulate_platform_node_pooluses at registration time — a single implementation backs both paths rather than two hand-duplicated copies. Wired intopersister.rs::load().Typed pool-key data integrity
apply_pools()rejects a write that would silently change already-storedpublic_key/key_typeat an existing pool index (WalletStorageError::TypedPoolKeyConflict), mirroring the existingProviderKeyAccountConflictfail-closed pattern at the account level. Identical re-writes (idempotent resync) and fresh indices are unaffected — only a genuine conflicting overwrite is rejected, since these hardened-derivation keys have no recovery path if clobbered.insert_platform_node_pool_entry's missing-AbsentHardened-pool case is a hard error in both call sites (previously one path silently no-op'd, the other errored — now unified).Testing
platform_node_key_public_keys_survive_sqlite_store_and_load— confirmed RED against the pre-fix code (left: 0, right: 3), GREEN after the V005 fix.conflicting_typed_pool_key_is_rejected_and_original_survives_load,identical_typed_pool_key_is_idempotent,typed_pool_key_at_fresh_index_succeeds— cover the conflict guard's reject/idempotent/fresh-index paths through the realstore()/load()API.populate_platform_node_pool_rejects_missing_hardened_pool— covers the unified missing-pool hard error.cargo test -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-features --all-targets: all green, 0 failures.cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets --all-features -- -D warnings: clean.cargo fmt: applied.sqlite_schema_pinning.rs) re-baselined for the V005 migration.store()/load()API before being classified and fixed.Breaking Changes
None. V005 is purely additive (nullable columns, existing rows unaffected). Retiring the old dedicated-batch migration (
V004__provider_key_accounts.rs, distinct from the current tree's V004, which isinvitations) is safe: it was introduced and removed within this same unmerged PR, so it was never released.Checklist:
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent