Skip to content

fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113)#4117

Open
Claudius-Maginificent wants to merge 18 commits into
feat/platform-wallet-storage-rehydrationfrom
feat/4113-provider-key-account-persistence
Open

fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113)#4117
Claudius-Maginificent wants to merge 18 commits into
feat/platform-wallet-storage-rehydrationfrom
feat/4113-provider-key-account-persistence

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Why this PR exists

  • Problem: rs-platform-wallet-storage's SQLite persister exhaustively destructures PlatformWalletChangeSet but only ever does let _ = provider_key_account_registrations; — BLS operator-key and EdDSA platform-node-key account registrations were silently dropped on every store(). 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.
  • Architecture shift mid-review: feat(platform-wallet): persist typed BLS/EdDSA provider keys as core address rows #4127 (merged during this PR's life, by a different author) retired this PR's original mechanism for persisting individual pre-derived platform-node Ed25519 keys (a dedicated batch: V004 child table, ProviderKeyRegistrationBlob, ProviderNodeKeyConflict) and replaced it with the generic account_address_pools pipeline already used for every other account type — but wired that replacement up for the FFI backend only. It never touched rs-platform-wallet-storage, leaving a fresh, undiscovered gap: the SQLite backend's generic pool writer persisted script_pubkey/used per address-pool entry but silently dropped public_key.
  • What breaks without it: Ed25519/SLIP-10 platform-node keys are hardened-only — there is no way to re-derive them from a watch-only account xpub. On a SQLite-backed store, register a wallet with provider accounts, restart, and the ProviderPlatformKeys pool comes back empty either way (old mechanism removed and never had SQLite coverage anyway, or new mechanism has the same gap) — provider_masternode_txs_blocking silently returns zero platform-node-owned masternodes.
  • Blocking relationship: stacked on feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration #3968 (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)

  • SQLite writer apply_provider_registrations / reader load_provider_state persist BLS/EdDSA provider account xpubs via ProviderKeyAccountEntry, wired into touched_domains and the store/load round trip.
  • build_wallet rebuilds BLS/EdDSA watch-only provider accounts from persisted rows via the shared rebuild_provider_key_account helper (used by both SQLite and FFI restore paths).
  • Fail-closed validation: curve/type pairing mismatches and conflicting xpubs for the same account are rejected before any SQL runs.

Platform-node public key persistence (new — completes #4127's generic pipeline for SQLite)

  • Removed the pre-feat(platform-wallet): persist typed BLS/EdDSA provider keys as core address rows #4127 dedicated node-key-batch mechanism entirely (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.
  • New migration V005__pool_public_key.rs — additive public_key BLOB NULL / key_type INTEGER NULL columns on core_address_pool.
  • apply_pools() (core_pool.rs) now persists AddressInfo::public_key alongside 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 the ProviderPlatformKeys account's AbsentHardened pool on load(), reconstructing each AddressInfo via the same shared insert_platform_node_pool_entry helper (rs-platform-wallet) that populate_platform_node_pool uses at registration time — a single implementation backs both paths rather than two hand-duplicated copies. Wired into persister.rs::load().

Typed pool-key data integrity

  • apply_pools() rejects a write that would silently change already-stored public_key/key_type at an existing pool index (WalletStorageError::TypedPoolKeyConflict), mirroring the existing ProviderKeyAccountConflict fail-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

  • New repro test 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 real store()/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.
  • Schema-pinning fingerprints (sqlite_schema_pinning.rs) re-baselined for the V005 migration.
  • Independently reviewed by three specialist passes (security, project-consistency, adversarial QA) before this push; the conflict guard was empirically reproduced against the real 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 is invitations) is safe: it was introduced and removed within this same unmerged PR, so it was never released.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any — n/a, no breaking changes
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 9 commits July 13, 2026 14:27
…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>
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b5449f08-9de8-4243-b70d-fa287368b5f8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/4113-provider-key-account-persistence

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lklimek lklimek marked this pull request as ready for review July 13, 2026 17:06
@thepastaclaw

thepastaclaw commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 3c26fc1)
Canonical validated blockers: 3

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
lklimek and others added 4 commits July 15, 2026 08:02
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

lklimek and others added 2 commits July 15, 2026 14:48
…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
@Claudius-Maginificent Claudius-Maginificent changed the title fix(platform-wallet-storage): persist provider_key_account_registrations (closes #4113) fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113) Jul 15, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +87 to +95
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)),
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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']

Comment on lines +131 to +150
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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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']

lklimek and others added 2 commits July 15, 2026 16:30
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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines 107 to 143
@@ -85,12 +138,102 @@ pub fn apply_pools(
i64::from(info.index),
info.script_pubkey.as_bytes(),
info.used,
public_key,
key_type,
])?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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']

Comment on lines +301 to +325
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants