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>, WalletStorageError> { + let (user_identity_id, friend_identity_id) = account_dashpay_ids(account_type); + let mut stmt = conn.prepare_cached( + "SELECT length(account_xpub_bytes), account_xpub_bytes \ + FROM account_registrations \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6", + )?; + let mut rows = stmt.query(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[..], + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + blob::check_size(row.get::<_, i64>(0)?)?; + Ok(Some(row.get::<_, Vec>(1)?)) +} + +/// True when the extended public key's curve is the one its account type +/// mandates. Enforced on both the write and the read path. +/// +/// `account_type` is the discriminator that decides the curve — there is no tag +/// byte inside the payload. The FFI backend's restore side picks the same +/// discriminator (`platform-wallet-ffi::persistence`, which branches on +/// `account_type` to choose its decode), so the two backends agree on *how* a +/// provider account is identified. A [`ProviderKeyAccountEntry`] whose payload +/// carries the other curve is cross-curve confusion, not a decodable account. +fn provider_curve_matches_type(at: &AccountType, key: &ProviderKeyExtendedPubKey) -> bool { + matches!( + (at, key), + ( + AccountType::ProviderOperatorKeys, + ProviderKeyExtendedPubKey::Bls(_) + ) | ( + AccountType::ProviderPlatformKeys, + ProviderKeyExtendedPubKey::EdDSA(_) + ) + ) +} + +/// Read the provider key-material accounts of one wallet. +/// +/// Entries are ordered by `account_type`; a row that fails to decode, +/// contradicts its typed columns, or carries the wrong curve for its account +/// type is a hard [`WalletStorageError`]. +pub(crate) fn load_provider_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id, \ + length(account_xpub_bytes), account_xpub_bytes FROM account_registrations \ + WHERE wallet_id = ?1 AND account_type IN ('provider_operator', 'provider_platform') \ + ORDER BY account_type", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + let typed_type: String = row.get(0)?; + let typed_index: i64 = row.get(1)?; + let typed_key_class: i64 = row.get(2)?; + let typed_user: Vec = row.get(3)?; + let typed_friend: Vec = row.get(4)?; + blob::check_size(row.get::<_, i64>(5)?)?; + let payload: Vec = row.get(6)?; + let entry = blob::decode::(&payload)?; + + // Same typed-column cross-check the ECDSA reader applies, plus the + // curve↔account-type agreement the provider rows add. + let (blob_user, blob_friend) = account_dashpay_ids(&entry.account_type); + let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.account_index", + typed_index, + )?; + let typed_key_class = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.key_class", + typed_key_class, + )?; + if account_type_db_label(&entry.account_type) != typed_type.as_str() + || account_index(&entry.account_type) != typed_index + || account_key_class(&entry.account_type) != typed_key_class + || blob_user.as_slice() != typed_user.as_slice() + || blob_friend.as_slice() != typed_friend.as_slice() + || !provider_curve_matches_type(&entry.account_type, &entry.extended_public_key) + { + return Err(WalletStorageError::ProviderKeyAccountEntryMismatch); + } + + out.push(ProviderKeyAccountEntry { + account_type: entry.account_type, + extended_public_key: entry.extended_public_key, + }); + } + Ok(out) +} + /// Read every `account_registrations` row for `wallet_id` into a keyless -/// [`AccountRegistrationEntry`] manifest — the rehydration account-set oracle -/// (which accounts to re-derive + the per-account xpubs the wrong-account gate -/// checks). PUBLIC material only (xpub + account type), no `Wallet` minted. -/// Ordered by `(account_type, account_index)` for determinism; a row that -/// fails to decode is a hard [`WalletStorageError`]. +/// [`AccountManifest`] — the rehydration account-set oracle (which accounts to +/// re-derive + the per-account xpubs the wrong-account gate checks). PUBLIC +/// material only (xpub + account type), no `Wallet` minted. Each list is +/// ordered by its typed columns for determinism; a row that fails to decode is +/// a hard [`WalletStorageError`]. pub fn load_state( conn: &Connection, wallet_id: &WalletId, +) -> Result { + Ok(AccountManifest { + ecdsa: load_ecdsa_state(conn, wallet_id)?, + provider: load_provider_state(conn, wallet_id)?, + }) +} + +/// The secp256k1 half of [`load_state`]: every row whose blob is an +/// [`AccountRegistrationEntry`]. +fn load_ecdsa_state( + conn: &Connection, + wallet_id: &WalletId, ) -> Result, WalletStorageError> { // Select typed columns alongside the blob so we can cross-check them // against the decoded entry — a row whose blob disagrees with its indexed @@ -173,10 +451,14 @@ pub fn load_state( // rather than silently mis-bucketed. // `length(account_xpub_bytes)` is read first (O(1) from the row header) so // an oversize blob is caught before the Vec is allocated. + // The provider key-material rows are excluded by `account_type`: their + // blob is a `ProviderKeyAccountEntry` over a non-secp256k1 curve and + // would hard-error this decode. let mut stmt = conn.prepare( "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id, \ length(account_xpub_bytes), account_xpub_bytes FROM account_registrations \ WHERE wallet_id = ?1 \ + AND account_type NOT IN ('provider_operator', 'provider_platform') \ ORDER BY account_type, account_index, key_class, user_identity_id, friend_identity_id", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; @@ -516,7 +798,9 @@ mod tests { ) .unwrap(); - let loaded = load_state(&conn, &w).expect("consistent row must load cleanly"); + let loaded = load_state(&conn, &w) + .expect("consistent row must load cleanly") + .ecdsa; assert_eq!(loaded.len(), 1); assert!(matches!( loaded[0].account_type, @@ -549,7 +833,7 @@ mod tests { apply_registrations(&tx, &w, &[entry(0), entry(1)]).unwrap(); tx.commit().unwrap(); } - let loaded = load_state(&conn, &w).expect("both key classes load"); + let loaded = load_state(&conn, &w).expect("both key classes load").ecdsa; assert_eq!(loaded.len(), 2, "distinct key classes must both persist"); let key_classes: HashSet = loaded .iter() @@ -587,7 +871,7 @@ mod tests { apply_registrations(&tx, &w, &[entry([0x01; 32]), entry([0x02; 32])]).unwrap(); tx.commit().unwrap(); } - let loaded = load_state(&conn, &w).expect("both contacts load"); + let loaded = load_state(&conn, &w).expect("both contacts load").ecdsa; assert_eq!(loaded.len(), 2, "distinct contacts must both persist"); let friends: HashSet<[u8; 32]> = loaded .iter() @@ -626,7 +910,7 @@ mod tests { apply_registrations(&tx, &w, std::slice::from_ref(&entry)).unwrap(); tx.commit().unwrap(); } - let loaded = load_state(&conn, &w).expect("load"); + let loaded = load_state(&conn, &w).expect("load").ecdsa; assert_eq!(loaded.len(), 1, "re-persist must not duplicate the row"); } @@ -695,6 +979,53 @@ mod tests { variants } + /// The reader's SQL inlines these two labels (SQLite has no list + /// binding), so a rename upstream must break here rather than silently + /// route every provider row into the ECDSA decode path. + #[test] + fn provider_key_account_labels_match_sql_literals() { + use key_wallet::account::AccountType; + assert_eq!( + account_type_db_label(&AccountType::ProviderOperatorKeys), + "provider_operator" + ); + assert_eq!( + account_type_db_label(&AccountType::ProviderPlatformKeys), + "provider_platform" + ); + } + + /// All four provider account types collapse to the same index / + /// key-class / DashPay sentinels, so `account_type` is the only thing + /// keeping them off each other's PK. Two that collided would silently + /// overwrite on upsert. + #[test] + fn provider_variants_have_distinct_pk_tuples() { + use key_wallet::account::AccountType; + let key = |at: AccountType| { + ( + account_type_db_label(&at), + account_index(&at), + account_key_class(&at), + account_dashpay_ids(&at), + ) + }; + let keys: Vec<_> = [ + AccountType::ProviderVotingKeys, + AccountType::ProviderOwnerKeys, + AccountType::ProviderOperatorKeys, + AccountType::ProviderPlatformKeys, + ] + .into_iter() + .map(key) + .collect(); + for k in &keys { + assert_eq!((k.1, k.2, k.3), (0, 0, ([0u8; 32], [0u8; 32]))); + } + let distinct: HashSet<_> = keys.iter().collect(); + assert_eq!(distinct.len(), 4, "provider PK tuples must not collide"); + } + #[test] fn account_type_labels_match_enum() { let from_writer: HashSet<&'static str> = all_account_type_variants() diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs index ca1e0d5738..ec7dbd4c10 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -16,7 +16,7 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use platform_wallet::changeset::AccountAddressPoolEntry; use platform_wallet::wallet::platform_wallet::WalletId; -use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::accounts; @@ -33,20 +33,40 @@ pub(crate) fn pool_type_to_i64(pool_type: AddressPoolType) -> i64 { } } +// Stable `PublicKeyType` declaration-order discriminants and emitted lengths. +const KEY_TYPE_ECDSA: i64 = 0; +const KEY_TYPE_EDDSA: i64 = 1; +const KEY_TYPE_BLS: i64 = 2; +const ECDSA_PUBLIC_KEY_LEN: usize = 33; +const EDDSA_PUBLIC_KEY_LEN: usize = 32; +const BLS_PUBLIC_KEY_LEN: usize = 48; + const UPSERT_POOL_SQL: &str = "INSERT INTO core_address_pool \ (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, \ - pool_type, address_index, script, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + pool_type, address_index, script, used, public_key, key_type) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) \ ON CONFLICT(wallet_id, account_type, account_index, key_class, user_identity_id, \ friend_identity_id, pool_type, address_index) \ DO UPDATE SET \ script = excluded.script, \ + public_key = excluded.public_key, \ + key_type = excluded.key_type, \ used = MAX(used, excluded.used)"; +const TYPED_POOL_CONFLICT_SQL: &str = "SELECT EXISTS( \ + SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6 \ + AND pool_type = ?7 AND address_index = ?8 \ + AND key_type IS NOT NULL AND public_key IS NOT NULL \ + AND (?10 IS NULL OR public_key <> ?9 OR key_type <> ?10) \ + )"; + /// Expand `account_address_pools` snapshots into per-index /// `core_address_pool` rows. Idempotent: `script` is derivation-stable and /// `used` is monotonic (`MAX`), so re-applying the same snapshot is a no-op /// and a used address can never revert to unused (the reuse-guard invariant). +/// Writes reject any change or erasure of typed key material already stored. pub fn apply_pools( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -55,7 +75,8 @@ pub fn apply_pools( if pools.is_empty() { return Ok(()); } - let mut stmt = tx.prepare_cached(UPSERT_POOL_SQL)?; + let mut conflict_stmt = tx.prepare_cached(TYPED_POOL_CONFLICT_SQL)?; + let mut upsert_stmt = tx.prepare_cached(UPSERT_POOL_SQL)?; for entry in pools { // `account_type` discriminates accounts that collapse to the same // `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` @@ -74,7 +95,57 @@ pub fn apply_pools( accounts::account_dashpay_ids(&entry.account_type); let pool_type = pool_type_to_i64(entry.pool_type); for info in &entry.addresses { - stmt.execute(params![ + let (public_key, key_type, expected_key_len): ( + Option<&[u8]>, + Option, + Option, + ) = match info.public_key.as_ref() { + None => (None, None, None), + Some(PublicKeyType::ECDSA(bytes)) => ( + Some(bytes.as_slice()), + Some(KEY_TYPE_ECDSA), + Some(ECDSA_PUBLIC_KEY_LEN), + ), + Some(PublicKeyType::EdDSA(bytes)) => ( + Some(bytes.as_slice()), + Some(KEY_TYPE_EDDSA), + Some(EDDSA_PUBLIC_KEY_LEN), + ), + Some(PublicKeyType::BLS(bytes)) => ( + Some(bytes.as_slice()), + Some(KEY_TYPE_BLS), + Some(BLS_PUBLIC_KEY_LEN), + ), + }; + if let (Some(public_key), Some(expected_key_len)) = (public_key, expected_key_len) { + blob::check_fixed_width( + i64::try_from(public_key.len()).unwrap_or(i64::MAX), + expected_key_len, + "core_address_pool.public_key has the wrong length for key_type", + )?; + } + let conflicts = conflict_stmt.query_row( + params![ + wallet_id.as_slice(), + account_type, + account_index, + key_class, + user_identity_id.as_slice(), + friend_identity_id.as_slice(), + pool_type, + i64::from(info.index), + public_key, + key_type, + ], + |row| row.get::<_, bool>(0), + )?; + if conflicts { + return Err(WalletStorageError::TypedPoolKeyConflict { + account_type, + address_index: info.index, + }); + } + upsert_stmt.execute(params![ wallet_id.as_slice(), account_type, account_index, @@ -85,12 +156,105 @@ pub fn apply_pools( i64::from(info.index), info.script_pubkey.as_bytes(), info.used, + public_key, + key_type, ])?; } } Ok(()) } +/// One restored typed-pool row: `(address_index, script_bytes, public_key, used)`. +pub type TypedPoolEntry = (u32, Vec, PublicKeyType, bool); + +/// Load typed public-key rows for one account pool, ordered by address index. +/// +/// These rows carry pre-derived hardened-only batches, such as platform-node +/// EdDSA keys, that cannot be regenerated from a watch-only account xpub. +/// Invalid key discriminants, missing keys, and wrong key widths are errors. +pub fn load_typed_pool_entries( + conn: &Connection, + wallet_id: &WalletId, + account_type: &key_wallet::account::AccountType, + pool_type: AddressPoolType, +) -> Result, WalletStorageError> { + let account_type = accounts::account_type_db_label(account_type); + let pool_type = pool_type_to_i64(pool_type); + let (max_script_len, max_public_key_len): (Option, Option) = 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 (public_key IS NOT NULL OR 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 (public_key IS NOT NULL OR key_type IS NOT NULL) \ + ORDER BY address_index", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice(), account_type, pool_type,])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + let address_index = crate::sqlite::util::safe_cast::i64_to_u32( + "core_address_pool.address_index", + row.get::<_, i64>(0)?, + )?; + blob::check_size(row.get::<_, i64>(1)?)?; + let script = row.get::<_, Vec>(2)?; + let public_key_len = row.get::<_, Option>(3)?; + let key_type = row.get::<_, Option>(5)?; + let (public_key_len, key_type) = match (public_key_len, key_type) { + (Some(public_key_len), Some(key_type)) => (public_key_len, key_type), + _ => { + return Err(WalletStorageError::blob_decode( + "core_address_pool.public_key and key_type nullability differ", + )); + } + }; + let expected_key_len = match key_type { + KEY_TYPE_ECDSA => ECDSA_PUBLIC_KEY_LEN, + KEY_TYPE_EDDSA => EDDSA_PUBLIC_KEY_LEN, + KEY_TYPE_BLS => BLS_PUBLIC_KEY_LEN, + _ => { + return Err(WalletStorageError::blob_decode( + "core_address_pool.key_type is outside 0..=2", + )); + } + }; + blob::check_fixed_width( + public_key_len, + expected_key_len, + "core_address_pool.public_key has the wrong length for key_type", + )?; + let Some(public_key) = row.get::<_, Option>>(4)? else { + return Err(WalletStorageError::blob_decode( + "core_address_pool.public_key is NULL for a typed row", + )); + }; + let public_key = match key_type { + KEY_TYPE_ECDSA => PublicKeyType::ECDSA(public_key), + KEY_TYPE_EDDSA => PublicKeyType::EdDSA(public_key), + KEY_TYPE_BLS => PublicKeyType::BLS(public_key), + _ => { + return Err(WalletStorageError::blob_decode( + "core_address_pool.key_type is outside 0..=2", + )); + } + }; + out.push((address_index, script, public_key, row.get(6)?)); + } + Ok(out) +} + /// Identity of the funds account that owns an address, matched against a /// `core_address_pool` row. Enough to select one account among funding accounts /// that share a numeric `account_index` (Standard BIP44/BIP32 and CoinJoin can diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs index a1724ef6ef..8504a3d55f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs @@ -80,11 +80,12 @@ impl Domain { /// Domains carrying data in `cs`. The destructure is exhaustive (no `..`), so /// adding a field to `PlatformWalletChangeSet` is a compile error here until /// it gains a `Domain` variant and an arm below — the R8 forgotten-domain -/// guard. `shielded` (feature-gated; storage versions no shielded state here) -/// is bound and deliberately ignored; `provider_key_account_registrations` is -/// not yet mapped to a `Domain`, but a non-empty one is surfaced via a -/// `tracing::warn` rather than dropped in silence (deferred — see its binding -/// below). +/// guard. One field is bound and deliberately ignored rather than mapped: +/// `shielded` (feature-gated; storage versions no shielded state here). +/// +/// `account_registrations` and `provider_key_account_registrations` share +/// [`Domain::AccountRegistrations`]: both land in `account_registrations` +/// rows, so one seq covers the whole account manifest. pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { let PlatformWalletChangeSet { core, @@ -108,19 +109,6 @@ pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { } = cs; #[cfg(feature = "shielded")] let _ = shielded; - // `provider_key_account_registrations` (BLS operator-key / EdDSA - // platform-node-key accounts) carries a live data path — both key types are - // default-on — but is not yet mapped to a `Domain`. Deferred, not dropped: - // nothing ever persisted it (this persister is new, so there is no - // regression), and no consumer reads it back yet. Tracked in - // https://github.com/dashpay/platform/issues/4113. - if !provider_key_account_registrations.is_empty() { - tracing::warn!( - count = provider_key_account_registrations.len(), - "provider-key account registrations in this changeset are not persisted yet \ - (deferred, tracked in dashpay/platform#4113) and will not survive a reload" - ); - } // A sub-changeset carried but empty (`Some(default)`) is not a real // change; the `Merge::is_empty` bound is the shared emptiness contract. @@ -162,7 +150,7 @@ pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { if wallet_metadata.is_some() { out.push(Domain::WalletMetadata); } - if !account_registrations.is_empty() { + if !account_registrations.is_empty() || !provider_key_account_registrations.is_empty() { out.push(Domain::AccountRegistrations); } if !account_address_pools.is_empty() { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index fd0342374c..1d644108fb 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -5,22 +5,29 @@ //! runs here; that gate lives in the resolver-backed signing entrypoints. use key_wallet::account::account_collection::AccountCollection; -use key_wallet::account::Account; +use key_wallet::account::{Account, AccountType}; +use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; -use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; +use platform_wallet::changeset::{ + rebuild_provider_key_account, AccountRegistrationEntry, CoreChangeSet, + ProviderAccountRebuildError, +}; +use platform_wallet::wallet::provider_key_at_index::{ + insert_platform_node_pool_entry, PlatformNodePoolError, +}; -use crate::sqlite::schema::accounts; -use crate::sqlite::schema::core_pool::OwningAccount; +use crate::sqlite::schema::accounts::{self, AccountManifest}; +use crate::sqlite::schema::core_pool::{self, OwningAccount}; use crate::WalletStorageError; /// Build a [`Wallet`] that will be provided to the platform-wallet during rehydration. pub(crate) fn build_wallet( network: Network, expected_wallet_id: [u8; 32], - manifest: &[AccountRegistrationEntry], + manifest: &AccountManifest, ) -> Result { if manifest.is_empty() { return Err(WalletStorageError::MissingAccount { @@ -28,7 +35,7 @@ pub(crate) fn build_wallet( }); } let mut accounts = AccountCollection::new(); - for entry in manifest { + for entry in &manifest.ecdsa { // `Account::from_xpub` is infallible in the pinned key-wallet rev; this // map_err is a defensive guard for when that signature becomes fallible. let account = Account::from_xpub( @@ -42,6 +49,25 @@ pub(crate) fn build_wallet( .insert(account) .map_err(|_| WalletStorageError::AccountRegistrationEntryMismatch)?; } + // Provider key-material accounts take the shared rebuild path (the FFI + // backend's restore side calls the same helper). + for entry in &manifest.provider { + rebuild_provider_key_account( + &mut accounts, + expected_wallet_id, + network, + entry.account_type, + &entry.extended_public_key, + ) + .map_err(|e| match e { + ProviderAccountRebuildError::Invalid(e) => { + WalletStorageError::AccountRecordInvalid { e } + } + ProviderAccountRebuildError::Rejected(_) => { + WalletStorageError::ProviderKeyAccountEntryMismatch + } + })?; + } Ok(Wallet::new_external_signable( network, expected_wallet_id, @@ -49,6 +75,68 @@ pub(crate) fn build_wallet( )) } +/// Restore pre-derived platform-node public keys from typed address-pool rows. +/// +/// The account's `AbsentHardened` EdDSA entries cannot be regenerated from a +/// watch-only xpub, so they round-trip verbatim through +/// [`core_pool::load_typed_pool_entries`]. +pub(crate) fn restore_provider_platform_node_pool( + wallet_info: &mut ManagedWalletInfo, + conn: &rusqlite::Connection, + wallet_id: &platform_wallet::wallet::platform_wallet::WalletId, + network: Network, +) -> Result<(), WalletStorageError> { + if wallet_info.accounts.provider_platform_keys.is_none() { + return Ok(()); + } + + let entries = core_pool::load_typed_pool_entries( + conn, + wallet_id, + &AccountType::ProviderPlatformKeys, + AddressPoolType::AbsentHardened, + )?; + if entries.is_empty() { + return Ok(()); + } + + for (index, script_bytes, public_key, used) in entries { + let PublicKeyType::EdDSA(public_key) = public_key else { + return Err(WalletStorageError::blob_decode( + "provider platform pool row does not carry an EdDSA public key", + )); + }; + let public_key = public_key.try_into().map_err(|_| { + WalletStorageError::blob_decode( + "provider platform pool row has the wrong EdDSA public-key length", + ) + })?; + let script_pubkey = dashcore::ScriptBuf::from_bytes(script_bytes); + let address = dashcore::Address::from_script(&script_pubkey, network)?; + insert_platform_node_pool_entry( + wallet_info, + network, + index, + address, + script_pubkey, + public_key, + used, + ) + .map_err(|error| match error { + PlatformNodePoolError::InvalidAccountPath { source } => { + WalletStorageError::AccountRecordInvalid { e: source } + } + PlatformNodePoolError::MissingHardenedPool => WalletStorageError::blob_decode( + "provider platform account has no AbsentHardened pool", + ), + PlatformNodePoolError::InvalidChildIndex { source, .. } => { + WalletStorageError::AccountRecordInvalid { e: source } + } + })?; + } + Ok(()) +} + /// Apply the keyless persisted core-state projection onto a /// freshly-minted `ManagedWalletInfo` skeleton. /// @@ -634,7 +722,11 @@ mod tests { ) .unwrap(); let id = w.compute_wallet_id(); - let manifest = manifest_for(&w); + let ecdsa = manifest_for(&w); + let manifest = AccountManifest { + ecdsa: ecdsa.clone(), + provider: Vec::new(), + }; let restored = build_wallet(Network::Testnet, id, &manifest).unwrap(); assert_eq!(restored.wallet_id, id); @@ -645,7 +737,7 @@ mod tests { .into_iter() .map(|a| a.account_type) .collect(); - let manifest_types: Vec<_> = manifest.iter().map(|e| e.account_type).collect(); + let manifest_types: Vec<_> = ecdsa.iter().map(|e| e.account_type).collect(); assert_eq!(restored_types.len(), manifest_types.len()); for t in &manifest_types { assert!(restored_types.contains(t)); @@ -654,7 +746,7 @@ mod tests { #[test] fn empty_manifest_is_missing_manifest() { - let err = build_wallet(Network::Testnet, [0u8; 32], &[]) + let err = build_wallet(Network::Testnet, [0u8; 32], &AccountManifest::default()) .expect_err("empty manifest must be MissingManifest"); assert!(matches!(err, WalletStorageError::MissingAccount { .. })); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs index dc07894329..0f8daf11b0 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs @@ -74,7 +74,7 @@ fn a1_account_registrations_roundtrip() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let manifest = accounts::load_state(&conn, &w).expect("load_state"); + let manifest = accounts::load_state(&conn, &w).expect("load_state").ecdsa; drop(conn); assert_eq!(manifest.len(), 2, "all rows must be returned"); @@ -112,7 +112,7 @@ fn a1_empty_manifest_is_ok() { drop(persister); let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let manifest = accounts::load_state(&conn, &w).expect("load_state"); + let manifest = accounts::load_state(&conn, &w).expect("load_state").ecdsa; drop(conn); assert!(manifest.is_empty()); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index d6e209d807..0f95aa9070 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -64,6 +64,12 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "accounts.rs", "SELECT wallet_id, account_index, key_class, length(account_xpub_bytes), account_xpub_bytes", ), + // Provider platform-node-key reader: pre-read length() gates on both + // fixed-width key columns. + ( + "accounts.rs", + "SELECT key_index, length(public_key), public_key, length(node_id), node_id", + ), // list_unspent_utxos (test-helper reader, ungated — global SQLITE_LIMIT_LENGTH covers it). ("core_state.rs", "SELECT outpoint, value, script, height"), // load_state unspent-UTXO reader: pre-read length() gates on outpoint and script. @@ -78,6 +84,12 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "core_pool.rs", "SELECT script, account_type, account_index, user_identity_id, friend_identity_id", ), + // Typed-pool reader: one-shot read-only scan of pre-derived platform-node + // keys per wallet, during `load()` rehydration. + ( + "core_pool.rs", + "SELECT address_index, length(script), script, length(public_key), public_key", + ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs index 63446728e1..de8b37861e 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs @@ -9,15 +9,19 @@ mod common; use common::{ensure_wallet_meta, fresh_persister, wid}; use key_wallet::account::{AccountType, StandardAccountType}; -use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::{AddressInfo, Network, Utxo}; use platform_wallet::changeset::{ - AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + AccountAddressPoolEntry, CoreChangeSet, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + WalletMetadataEntry, }; use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_pool; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; /// Real external-pool `AddressInfo`s for a wallet's Standard BIP44 account 0, /// sorted by derivation index — genuine scripts that round-trip. @@ -81,6 +85,76 @@ fn pool_entry( } } +fn wallet_storage_error(err: PersistenceError) -> Box { + let source = match err { + PersistenceError::Backend { source, .. } => source, + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + source + .downcast::() + .unwrap_or_else(|source| panic!("expected WalletStorageError, got {source}")) +} + +fn provider_platform_registration(wallet: &Wallet) -> ProviderKeyAccountEntry { + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA( + wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA platform account") + .ed25519_public_key + .clone(), + ), + } +} + +fn typed_platform_node_info(seed_byte: u8, index: u32, key_byte: u8) -> AddressInfo { + let mut info = external_infos(seed_byte) + .into_iter() + .nth(index as usize) + .expect("derived address at requested index"); + info.public_key = Some(PublicKeyType::EdDSA(vec![key_byte; 32])); + info +} + +fn provider_platform_pool_entry(addresses: Vec) -> AccountAddressPoolEntry { + pool_entry( + AccountType::ProviderPlatformKeys, + AddressPoolType::AbsentHardened, + addresses, + ) +} + +fn loaded_provider_platform_infos( + persister: &SqlitePersister, + wallet_id: &WalletId, +) -> Vec { + let state = persister.load().expect("load wallet state"); + let wallet_info = &state + .wallets + .get(wallet_id) + .expect("wallet rehydrated") + .wallet_info; + let account = wallet_info + .all_managed_accounts() + .into_iter() + .find(|managed| { + managed.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .expect("restored platform-node managed account"); + account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .expect("restored platform-node hardened pool") + .addresses + .values() + .cloned() + .collect() +} + /// TC-B-001 — six pool rows with `used` set on indices {0,2,4}; the pool /// table is a first-class row store, not a `core_utxos` derivation. #[test] @@ -583,3 +657,425 @@ fn distinct_dashpay_friends_do_not_collide_in_pool() { .unwrap(); assert_eq!(total, 2, "both contacts must persist as separate rows"); } + +/// Repro for a real gap found while merging PR #4117 with upstream PR #4127. +/// PR #4127 replaced the removed `derived_platform_node_keys` persistence with +/// generic `account_address_pools` snapshots, but SQLite's `core_pool.rs` and +/// `persister.rs` do not carry or restore the raw platform-node public key. +/// Reference: dashpay/platform#4113. +#[test] +fn platform_node_key_public_keys_survive_sqlite_store_and_load() { + let wallet = Wallet::from_seed_bytes( + [0x33u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let keys = platform_wallet::wallet::provider_key_at_index::derive_platform_node_public_keys( + &wallet, + Network::Testnet, + 3, + ) + .expect("derive"); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); + platform_wallet::wallet::provider_key_at_index::populate_platform_node_pool( + &mut wallet_info, + &keys, + Network::Testnet, + ) + .expect("populate"); + + let platform_node_account = wallet_info + .all_managed_accounts() + .into_iter() + .find(|managed| { + managed.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .expect("platform-node managed account"); + let platform_node_pool = platform_node_account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .expect("platform-node hardened pool"); + let addresses = platform_node_pool + .addresses + .values() + .cloned() + .collect::>(); + assert_eq!( + addresses.len(), + 3, + "the in-memory platform-node pool must contain all three derived keys" + ); + assert!( + addresses.iter().all(|info| info.public_key.is_some()), + "the in-memory platform-node pool must carry every derived public key" + ); + let pool_entry = AccountAddressPoolEntry { + account_type: AccountType::ProviderPlatformKeys, + pool_type: AddressPoolType::AbsentHardened, + addresses, + }; + let provider_registration = ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA( + wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("eddsa account") + .ed25519_public_key + .clone(), + ), + }; + + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0x99); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_registration], + account_address_pools: vec![pool_entry], + ..Default::default() + }, + ) + .expect("store"); + drop(persister); + + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("reopen persister"); + let state = persister.load().expect("load"); + let restored = &state + .wallets + .get(&w) + .expect("wallet rehydrated") + .wallet_info; + let restored_platform_node_account = restored + .all_managed_accounts() + .into_iter() + .find(|managed| { + managed.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .expect("restored platform-node managed account"); + let restored_platform_node_pool = restored_platform_node_account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .expect("restored platform-node hardened pool"); + + assert_eq!( + restored_platform_node_pool.addresses.len(), + 3, + "all three platform-node indices must survive SQLite store()->load()" + ); + for key in &keys { + let restored_info = restored_platform_node_pool + .addresses + .get(&key.index) + .unwrap_or_else(|| panic!("platform-node index {} did not survive SQLite", key.index)); + let expected = Some(PublicKeyType::EdDSA(key.public_key.to_vec())); + assert_eq!( + restored_info.public_key, expected, + "platform-node public key at index {} did not survive SQLite store()->load() \ + — see doc comment: core_pool.rs never persists AddressInfo.public_key", + key.index + ); + } +} + +#[test] +fn conflicting_typed_pool_key_is_rejected_and_original_survives_load() { + let wallet = Wallet::from_seed_bytes( + [0xA1; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9A); + let first = typed_platform_node_info(0xA2, 0, 0x11); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![first.clone()])], + ..Default::default() + }, + ) + .expect("store original typed pool key"); + + let fresh_sibling = typed_platform_node_info(0xA2, 1, 0x22); + let mut conflicting = first.clone(); + conflicting.public_key = Some(PublicKeyType::EdDSA(vec![0x33; 32])); + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![ + fresh_sibling, + conflicting, + ])], + ..Default::default() + }, + ) + .expect_err("a different typed key at the same pool index must be rejected"); + let storage_error = wallet_storage_error(err); + assert_eq!(storage_error.error_kind_str(), "typed_pool_key_conflict"); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 1, "the rejected flush must be atomic"); + assert_eq!( + restored[0].public_key, first.public_key, + "the original typed key must remain intact" + ); +} + +#[test] +fn untyped_pool_key_cannot_overwrite_persisted_typed_key() { + let wallet = Wallet::from_seed_bytes( + [0xA3; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9D); + let original = typed_platform_node_info(0xA4, 0, 0x71); + let mut untyped = original.clone(); + untyped.public_key = None; + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![untyped.clone()])], + ..Default::default() + }, + ) + .expect("store initial untyped pool row"); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![original.clone()])], + ..Default::default() + }, + ) + .expect("upgrade untyped pool row with typed key material"); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![untyped])], + ..Default::default() + }, + ) + .expect_err("an untyped row must not erase a persisted typed key"); + assert_eq!( + wallet_storage_error(err).error_kind_str(), + "typed_pool_key_conflict" + ); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 1, "the rejected flush must be atomic"); + assert_eq!(restored[0].public_key, original.public_key); +} + +#[test] +fn malformed_typed_pool_key_widths_are_rejected_before_insert() { + let malformed_keys = [ + PublicKeyType::ECDSA(vec![0x11; 32]), + PublicKeyType::EdDSA(vec![0x22; 31]), + PublicKeyType::BLS(vec![0x33; 47]), + ]; + + for (case, malformed_key) in malformed_keys.into_iter().enumerate() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA0 + case as u8); + ensure_wallet_meta(&persister, &w); + let mut info = external_infos(0xA5 + case as u8) + .into_iter() + .next() + .expect("derived address"); + info.public_key = Some(malformed_key); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![info], + )], + ..Default::default() + }, + ) + .expect_err("a malformed typed key must be rejected before commit"); + assert_eq!(wallet_storage_error(err).error_kind_str(), "blob_decode"); + + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .expect("count pool rows"); + assert_eq!(count, 0, "malformed key case {case} reached the database"); + } +} + +#[test] +fn typed_pool_loader_rejects_mismatched_key_nullability() { + for (case, clear_column) in ["key_type", "public_key"].into_iter().enumerate() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB0 + case as u8); + ensure_wallet_meta(&persister, &w); + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let mut info = external_infos(0xB5 + case as u8) + .into_iter() + .next() + .expect("derived address"); + info.public_key = Some(PublicKeyType::ECDSA(vec![0x44; 33])); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + account_type.clone(), + AddressPoolType::External, + vec![info], + )], + ..Default::default() + }, + ) + .expect("store valid typed pool row"); + + let conn = persister.lock_conn_for_test(); + conn.execute( + &format!("UPDATE core_address_pool SET {clear_column} = NULL WHERE wallet_id = ?1"), + rusqlite::params![w.as_slice()], + ) + .expect("corrupt paired nullable columns"); + let err = + core_pool::load_typed_pool_entries(&conn, &w, &account_type, AddressPoolType::External) + .expect_err("mismatched typed-key nullability must fail hard"); + assert_eq!(err.error_kind_str(), "blob_decode", "case {clear_column}"); + } +} + +#[test] +fn identical_typed_pool_key_is_idempotent() { + let wallet = Wallet::from_seed_bytes( + [0xB1; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9B); + let info = typed_platform_node_info(0xB2, 0, 0x44); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![info.clone()])], + ..Default::default() + }, + ) + .expect("store original typed pool key"); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![info.clone()])], + ..Default::default() + }, + ) + .expect("re-store identical typed pool key"); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].public_key, info.public_key); +} + +#[test] +fn typed_pool_key_at_fresh_index_succeeds() { + let wallet = Wallet::from_seed_bytes( + [0xC1; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9C); + let first = typed_platform_node_info(0xC2, 0, 0x55); + let fresh = typed_platform_node_info(0xC2, 1, 0x66); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![first])], + ..Default::default() + }, + ) + .expect("store initial typed pool key"); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![fresh.clone()])], + ..Default::default() + }, + ) + .expect("store typed pool key at a fresh index"); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 2); + assert_eq!( + restored + .iter() + .find(|info| info.index == fresh.index) + .expect("fresh index restored") + .public_key, + fresh.public_key + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 4857a197de..88ed3bed26 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -162,6 +162,14 @@ fn samples() -> Vec { source: dashcore::address::Error::UnrecognizedScript, }, WalletStorageError::AccountRegistrationEntryMismatch, + WalletStorageError::ProviderKeyAccountEntryMismatch, + WalletStorageError::ProviderKeyAccountConflict { + account_type: "provider_platform", + }, + WalletStorageError::TypedPoolKeyConflict { + account_type: "provider_platform", + address_index: 0, + }, // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec // need real upstream errors; omitted but covered by their arms. WalletStorageError::BlobDecode { @@ -278,6 +286,13 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::AccountRegistrationEntryMismatch => { (false, "account_registration_entry_mismatch") } + WalletStorageError::ProviderKeyAccountEntryMismatch => { + (false, "provider_key_account_entry_mismatch") + } + WalletStorageError::ProviderKeyAccountConflict { .. } => { + (false, "provider_key_account_conflict") + } + WalletStorageError::TypedPoolKeyConflict { .. } => (false, "typed_pool_key_conflict"), WalletStorageError::MissingAccount { .. } => { (false, "missing_account_registration_entry") } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs index 942408ac40..1fcb15381a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs @@ -13,6 +13,7 @@ use std::path::{Path, PathBuf}; use common::{ro_conn, wid}; use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::migrations as mig; use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; use rusqlite::Connection; @@ -91,7 +92,11 @@ fn count(conn: &Connection, sql: &str, wallet: &[u8; 32]) -> i64 { /// Assert the post-migration store carries the full fixture data intact. fn assert_full_data_preserved(conn: &Connection) { let full = wid(FULL_WALLET); - assert_eq!(schema_version(conn), 4, "must be migrated to V004"); + assert_eq!( + schema_version(conn), + mig::max_supported_version(), + "must be migrated to the newest embedded version" + ); assert_eq!( conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) .unwrap(), @@ -222,9 +227,12 @@ fn tc_b_032_pre_migration_backup_created() { .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("pre-migration-1-to-4-") && n.ends_with(".db")) + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| { + n.starts_with(&format!( + "pre-migration-1-to-{}-", + mig::max_supported_version() + )) && n.ends_with(".db") + }) }) .expect("pre-migration backup must exist"); @@ -267,9 +275,12 @@ fn tc_b_033_backup_restorable_and_remigration_deterministic() { .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("pre-migration-1-to-4-")) + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| { + n.starts_with(&format!( + "pre-migration-1-to-{}-", + mig::max_supported_version() + )) + }) }) .expect("backup exists"); @@ -285,8 +296,8 @@ fn tc_b_033_backup_restorable_and_remigration_deterministic() { assert_full_data_preserved(&conn); } -/// TC-B-034 — the forward-version gate now rejects at the NEW max (4); a -/// forged version-5 row is refused. +/// TC-B-034 — the forward-version gate rejects at the newest embedded +/// version; a forged row one version past it is refused. #[test] fn tc_b_034_forward_version_rejected_at_new_max() { let tmp = tempfile::tempdir().unwrap(); @@ -294,12 +305,13 @@ fn tc_b_034_forward_version_rejected_at_new_max() { { let _p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); } + let forged = mig::max_supported_version() + 1; { let conn = Connection::open(&path).unwrap(); conn.execute( "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ - VALUES (5, 'future', '', '0')", - [], + VALUES (?1, 'future', '', '0')", + rusqlite::params![forged], ) .unwrap(); } @@ -308,8 +320,12 @@ fn tc_b_034_forward_version_rejected_at_new_max() { found, max_supported, }) => { - assert_eq!(found, 5); - assert_eq!(max_supported, 4, "max must reflect the post-redirect V004"); + assert_eq!(found, forged); + assert_eq!( + max_supported, + mig::max_supported_version(), + "max must reflect the newest embedded migration" + ); } Err(other) => panic!("expected SchemaVersionUnsupported, got {other:?}"), Ok(_) => panic!("forward-version DB must be refused"), @@ -371,7 +387,11 @@ fn tc_b_035_interrupted_migration_recovers_to_clean_state() { let conn = p.lock_conn_for_test(); migration_snapshot(&conn) }; - assert_eq!(clean_snapshot[0], 4, "clean migration reaches V004"); + assert_eq!( + clean_snapshot[0], + mig::max_supported_version(), + "clean migration reaches the newest embedded version" + ); // Crash simulation: apply part of V003's DDL inside a transaction that is // rolled back before commit — exactly what a crash before the migration's @@ -440,6 +460,10 @@ fn reopen_of_migrated_store_is_idempotent() { let conn = p.lock_conn_for_test(); read(&conn) }; - assert_eq!(first.0[0], 4, "first open migrates to V004"); + assert_eq!( + first.0[0], + mig::max_supported_version(), + "first open migrates to the newest embedded version" + ); assert_eq!(first, second, "reopen is a byte-stable no-op"); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs b/packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs new file mode 100644 index 0000000000..6fd1ba14b9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs @@ -0,0 +1,720 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Provider key-material account persistence (dashpay/platform#4113): the BLS +//! operator-key and EdDSA platform-node-key accounts survive `store()` → +//! `load()` on par with the ECDSA `account_registrations` manifest. +//! +//! Test-case IDs follow `docs/testspec-4113.md` (`TC-PKA-0NN`). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::AccountType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use platform_wallet::changeset::{ + AccountRegistrationEntry, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, WalletMetadataEntry, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::versions::{self, Domain}; +use platform_wallet_storage::sqlite::schema::{accounts, blob}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +/// Deterministic seed wallet carrying both provider key-material accounts +/// (`WalletAccountCreationOptions::Default` creates every special-purpose +/// account, BLS operator + EdDSA platform node included). +fn seed_wallet(seed: u8) -> Wallet { + Wallet::from_seed_bytes( + [seed; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet") +} + +/// The wallet's BLS operator-key account xpub. +fn bls_xpub(seed: u8) -> ProviderKeyExtendedPubKey { + ProviderKeyExtendedPubKey::Bls( + seed_wallet(seed) + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS operator account") + .bls_public_key + .clone(), + ) +} + +/// The wallet's EdDSA platform-node-key account xpub. +fn eddsa_xpub(seed: u8) -> ProviderKeyExtendedPubKey { + ProviderKeyExtendedPubKey::EdDSA( + seed_wallet(seed) + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA platform account") + .ed25519_public_key + .clone(), + ) +} + +fn operator_entry(seed: u8) -> ProviderKeyAccountEntry { + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: bls_xpub(seed), + } +} + +fn platform_entry(seed: u8) -> ProviderKeyAccountEntry { + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: eddsa_xpub(seed), + } +} + +fn metadata() -> WalletMetadataEntry { + WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + } +} + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen persister") +} + +/// Raw Ed25519 public-key bytes of an extended key, for cross-curve +/// comparison without leaning on a `PartialEq` the BLS type does not have. +fn eddsa_bytes(key: &ProviderKeyExtendedPubKey) -> Vec { + match key { + ProviderKeyExtendedPubKey::EdDSA(k) => k.encode().to_vec(), + other => panic!("expected an EdDSA key, got {other:?}"), + } +} + +/// BLS public-key bytes of an extended key (the G2 element), same purpose. +fn bls_bytes(key: &ProviderKeyExtendedPubKey) -> Vec { + match key { + ProviderKeyExtendedPubKey::Bls(k) => k.public_key.to_bytes().to_vec(), + other => panic!("expected a BLS key, got {other:?}"), + } +} + +fn wallet_storage_error(err: PersistenceError) -> Box { + let source = match err { + PersistenceError::Backend { source, .. } => source, + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + source + .downcast::() + .unwrap_or_else(|source| panic!("expected WalletStorageError, got {source}")) +} + +/// TC-PKA-001 — a reloaded wallet gets its BLS operator and EdDSA +/// platform-node accounts back. The end-to-end contract #4113 exists for: +/// a seedless/external-signable wallet can list its provider key accounts +/// after a restart without the mnemonic. +#[test] +fn tc_pka_001_provider_accounts_survive_store_load() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xC1); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(metadata()), + provider_key_account_registrations: vec![ + operator_entry(0x21), + platform_entry(0x21), + ], + ..Default::default() + }, + ) + .expect("store"); + drop(persister); + + let persister = reopen(&path); + let state = persister.load().expect("load"); + let restored = &state.wallets.get(&w).expect("wallet rehydrated").wallet; + + let bls = restored + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS operator account must come back from persistence"); + assert_eq!( + bls.bls_public_key.public_key.to_bytes().to_vec(), + bls_bytes(&bls_xpub(0x21)), + "the restored BLS account must carry the persisted operator xpub" + ); + assert!(bls.is_watch_only, "a rehydrated account is watch-only"); + + let eddsa = restored + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA platform account must come back from persistence"); + assert_eq!( + eddsa.ed25519_public_key.encode().to_vec(), + eddsa_bytes(&eddsa_xpub(0x21)), + "the restored EdDSA account must carry the persisted platform-node xpub" + ); + assert!(eddsa.is_watch_only, "a rehydrated account is watch-only"); +} + +/// TC-PKA-008 — a changeset carrying only provider-key registrations bumps +/// the `account_registrations` domain seq and no other. The forgotten-domain +/// guard (R8): a field that reaches the DB must invalidate a cache. +#[test] +fn tc_pka_008_provider_only_changeset_bumps_account_registrations_domain() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC2); + ensure_wallet_meta(&persister, &w); + + let cs = PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x22)], + ..Default::default() + }; + assert_eq!( + versions::touched_domains(&cs), + vec![Domain::AccountRegistrations], + "provider-key registrations must map to the account-registrations domain" + ); + + persister.store(w, cs).expect("store"); + + let conn = persister.lock_conn_for_test(); + for domain in Domain::ALL { + let seq = versions::read_seq(&conn, &w, domain).expect("read_seq"); + let expected = i64::from(domain == Domain::AccountRegistrations); + assert_eq!( + seq, expected, + "{domain:?} seq must be {expected} after a provider-only store" + ); + } +} + +/// TC-PKA-002 — a BLS operator account decodes back as BLS, never as the +/// other curve, and its xpub bytes survive verbatim. +#[test] +fn tc_pka_002_bls_decodes_as_bls() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC3); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x23)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w) + .expect("load_state") + .provider; + assert_eq!(provider.len(), 1); + assert!( + matches!( + provider[0].extended_public_key, + ProviderKeyExtendedPubKey::Bls(_) + ), + "a ProviderOperatorKeys account must decode as BLS, got {:?}", + provider[0].extended_public_key + ); + assert_eq!( + bls_bytes(&provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x23)), + "BLS xpub must survive byte-for-byte" + ); +} + +/// TC-PKA-003 — an EdDSA account decodes as EdDSA, and a row whose +/// `account_type` column claims the other curve's account is rejected: the +/// column is the decode discriminator, so cross-curve confusion must be a +/// hard error, never a silently-wrong account. +#[test] +fn tc_pka_003_eddsa_decodes_as_eddsa_and_cross_curve_row_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC4); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![platform_entry(0x24)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w) + .expect("load_state") + .provider; + assert_eq!(provider.len(), 1); + assert!( + matches!( + provider[0].extended_public_key, + ProviderKeyExtendedPubKey::EdDSA(_) + ), + "a ProviderPlatformKeys account must decode as EdDSA" + ); + assert_eq!( + eddsa_bytes(&provider[0].extended_public_key), + eddsa_bytes(&eddsa_xpub(0x24)), + "EdDSA xpub must survive byte-for-byte" + ); + + drop(conn); + + // Negative half, on a BLS row. Two corruptions the writer cannot produce + // but a schema bug or a tampered DB can, each rejected by a different + // guard: + // (a) the blob's account type contradicts the `account_type` column; + // (b) the column and the blob agree on the type, but the blob carries + // the other curve's key — only the curve check catches this one. + let w2: WalletId = wid(0xD4); + ensure_wallet_meta(&persister, &w2); + persister + .store( + w2, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x24)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let plant = |payload: Vec| { + conn.execute( + "UPDATE account_registrations SET account_xpub_bytes = ?2 WHERE wallet_id = ?1", + rusqlite::params![w2.as_slice(), payload], + ) + .expect("plant corrupt provider row"); + }; + + for (case, payload) in [ + ( + "blob type contradicts the account_type column", + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: eddsa_xpub(0x24), + }, + ), + ( + "blob carries the wrong curve for its account type", + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: eddsa_xpub(0x24), + }, + ), + ] { + plant(blob::encode(&payload).expect("encode")); + let err = accounts::load_state(&conn, &w2).expect_err("cross-curve row must hard-error"); + assert!( + matches!(err, WalletStorageError::ProviderKeyAccountEntryMismatch), + "{case}: expected ProviderKeyAccountEntryMismatch, got {err:?}" + ); + } +} + +/// TC-PKA-005 — no provider accounts is a clean round-trip: no rows, no +/// error, and the ECDSA manifest beside it is untouched. +#[test] +fn tc_pka_005_empty_provider_set_round_trips() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC6); + ensure_wallet_meta(&persister, &w); + + let ecdsa = AccountRegistrationEntry { + account_type: AccountType::IdentityRegistration, + account_xpub: seed_wallet(0x26) + .accounts + .all_accounts() + .first() + .expect("an account") + .account_xpub, + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![ecdsa], + provider_key_account_registrations: vec![], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w).expect("load_state"); + assert!(manifest.provider.is_empty(), "no provider accounts stored"); + assert_eq!(manifest.ecdsa.len(), 1, "the ECDSA entry is unaffected"); +} + +/// TC-PKA-009 — a corrupt provider blob fails the whole load. Skipping the +/// row would hand back a wallet silently missing its operator account. +#[test] +fn tc_pka_009_corrupt_provider_blob_hard_errors() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC7); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x27)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE account_registrations SET account_xpub_bytes = X'00' WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + ) + .expect("corrupt the blob"); + + let err = accounts::load_state(&conn, &w).expect_err("a corrupt provider blob must hard-error"); + assert!( + matches!(err, WalletStorageError::BincodeDecode { .. }), + "expected a typed BincodeDecode, got {err:?}" + ); +} + +/// TC-PKA-010 — an oversize provider blob is rejected by the `length()` gate +/// before the `Vec` is materialized. +#[test] +fn tc_pka_010_oversize_provider_blob_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC8); + ensure_wallet_meta(&persister, &w); + + let oversize = vec![0u8; blob::BLOB_SIZE_LIMIT_BYTES + 1]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'provider_operator', 0, ?2)", + rusqlite::params![w.as_slice(), oversize], + ) + .expect("insert oversize provider blob"); + + let err = accounts::load_state(&conn, &w).expect_err("oversize blob must be rejected"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +/// TC-PKA-015 — a retried `store()` updates the account row in place. +#[test] +fn tc_pka_015_idempotent_repersist_does_not_duplicate() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xCA); + ensure_wallet_meta(&persister, &w); + + for _ in 0..2 { + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![ + operator_entry(0x2A), + platform_entry(0x2A), + ], + ..Default::default() + }, + ) + .expect("store"); + } + + let conn = persister.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w).expect("load_state"); + assert_eq!(manifest.provider.len(), 2, "re-persist must not duplicate"); +} + +/// SEC-001 — the writer enforces the curve/type pairing the reader enforces. +/// The two writers share one table, one PK space and one blob column, +/// discriminated only by `account_type`: a `ProviderOperatorKeys` entry +/// carrying an EdDSA key would upsert onto the operator account's row with a +/// payload the fail-hard reader then rejects — bricking `load()` for the whole +/// wallet. Reject it at write time instead of storing a landmine. +#[test] +fn sec_001_writer_rejects_mispaired_curve_and_account_type() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + // ProviderOperatorKeys (BLS by contract) carrying an EdDSA key. + let mispaired = ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: eddsa_xpub(0x31), + }; + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![mispaired], + ..Default::default() + }, + ) + .expect_err("a mis-paired provider entry must be rejected at write time"); + assert!( + format!("{err:?}").contains("ProviderKeyAccountEntryMismatch"), + "expected a curve/type mismatch, got {err:?}" + ); + + let conn = persister.lock_conn_for_test(); + assert_eq!( + account_row_count(&conn, &w), + 0, + "a rejected entry must leave no row behind" + ); +} + +/// Two entries for one account that disagree about its extended public key are +/// a contradiction no merge semantic can resolve — one is wrong and the store +/// cannot tell which. Fail closed rather than let write order pick the winner. +#[test] +fn conflicting_duplicate_provider_entries_are_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD2); + ensure_wallet_meta(&persister, &w); + + // Same account type, two different seeds → two different xpubs. + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![ + platform_entry(0x32), + platform_entry(0x33), + ], + ..Default::default() + }, + ) + .expect_err("conflicting entries for one account must be rejected"); + assert!( + format!("{err:?}").contains("ProviderKeyAccountConflict"), + "expected ProviderKeyAccountConflict, got {err:?}" + ); + + let conn = persister.lock_conn_for_test(); + assert_eq!( + account_row_count(&conn, &w), + 0, + "neither entry may be written" + ); +} + +/// A BLS provider account cannot change xpub across flushes. +#[test] +fn conflicting_provider_xpub_against_persisted_row_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD6); + ensure_wallet_meta(&persister, &w); + + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x36)], + ..Default::default() + }, + ) + .expect("store original provider account"); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x37)], + ..Default::default() + }, + ) + .expect_err("a persisted provider account must reject a different xpub"); + let storage_error = wallet_storage_error(err); + assert!(matches!( + *storage_error, + WalletStorageError::ProviderKeyAccountConflict { + account_type: "provider_operator" + } + )); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w).expect("load original provider account"); + assert_eq!(provider.provider.len(), 1); + assert_eq!( + bls_bytes(&provider.provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x36)), + "the rejected store must leave the original xpub untouched" + ); +} + +#[test] +fn conflicting_provider_account_rejects_whole_batch_before_any_write() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD8); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x3A)], + ..Default::default() + }, + ) + .expect("store original operator account"); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![ + operator_entry(0x3B), + platform_entry(0x3C), + ], + ..Default::default() + }, + ) + .expect_err("a conflicting account must reject the whole provider batch"); + assert!(matches!( + *wallet_storage_error(err), + WalletStorageError::ProviderKeyAccountConflict { + account_type: "provider_operator" + } + )); + + let conn = persister.lock_conn_for_test(); + let platform_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM account_registrations \ + WHERE wallet_id = ?1 AND account_type = 'provider_platform'", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .expect("count platform accounts"); + assert_eq!( + platform_rows, 0, + "the rejected batch must not partially write its new platform account" + ); + let provider = accounts::load_state(&conn, &w).expect("load original operator account"); + assert_eq!(provider.provider.len(), 1); + assert_eq!( + bls_bytes(&provider.provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x3A)), + "the rejected batch must leave the original operator xpub untouched" + ); +} + +/// Provider-only labels cannot bypass the parent guard through the ordinary +/// secp256k1 account-registration field. +#[test] +fn ecdsa_registration_path_rejects_provider_account_labels() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD7); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x38)], + ..Default::default() + }, + ) + .expect("store original provider account"); + + let ecdsa_xpub = seed_wallet(0x39) + .accounts + .all_accounts() + .first() + .expect("an ECDSA account") + .account_xpub; + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![AccountRegistrationEntry { + account_type: AccountType::ProviderOperatorKeys, + account_xpub: ecdsa_xpub, + }], + ..Default::default() + }, + ) + .expect_err("provider labels must be rejected by the ECDSA writer"); + assert!(matches!( + *wallet_storage_error(err), + WalletStorageError::ProviderKeyAccountEntryMismatch + )); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w).expect("load original provider account"); + assert_eq!(provider.provider.len(), 1); + assert_eq!( + bls_bytes(&provider.provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x38)), + "the rejected alternate-path store must leave the original xpub untouched" + ); +} + +/// QA-002 — the provider write path rides the flush transaction: when a later +/// writer in the same `store()` fails, the account row and domain-seq bump roll +/// back together. Mirrors `tc_b_012`, which pins the same invariant for the +/// pool writer. +#[test] +fn qa_002_partial_failure_rolls_back_provider_rows_and_bump() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD3); + ensure_wallet_meta(&persister, &w); + + // No `identities` row for this id → the token-balances writer trips an FK + // violation after the provider rows have already been written in this tx. + let mut balances = std::collections::BTreeMap::new(); + balances.insert( + ( + dpp::prelude::Identifier::from([0xEE; 32]), + dpp::prelude::Identifier::from([0xEF; 32]), + ), + 1u64, + ); + let result = persister.store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![platform_entry(0x34)], + token_balances: Some(platform_wallet::changeset::TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(result.is_err(), "the FK violation must fail the flush"); + + let conn = persister.lock_conn_for_test(); + assert_eq!( + account_row_count(&conn, &w), + 0, + "the provider account row must roll back with the failed flush" + ); + assert_eq!( + versions::read_seq(&conn, &w, Domain::AccountRegistrations).expect("read_seq"), + 0, + "the domain bump must roll back with the data it marks" + ); +} + +/// Provider account-registration rows for one wallet, counted straight from SQL. +fn account_row_count(conn: &rusqlite::Connection, wallet_id: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1 \ + AND account_type IN ('provider_operator', 'provider_platform')", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .expect("count provider account rows") +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index b153cceed1..2080c685de 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::migrations as mig; /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "365c92e645ea15208204f2921e10a469bf30f6a727b7571381bbff9331612e76"; + "4c4b9db624b4924a71e57a44abe12402f76ca9cbfb9b4c945813dffe48fcf1db"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump deliberately only when the DDL body itself changes; an accidental /// change (a silent table rename) must fail this test, not slip through. const EXPECTED_SQL_FINGERPRINT: &str = - "6b790ca2b6bc96e503b6a3d7c49be10bc7afb7c88603aa30e26af2e97171f5fd"; + "378d089d8e60e8985c46c9ecffcd4034f411a62438ec2396d6d169eb80dc4a9b"; /// Table names that lost the cross-branch reconciliation and must never /// resurface as SQL identifiers on this frozen (`wallets`) baseline. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs index d825d3f811..0dac4446c5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs @@ -51,24 +51,32 @@ impl OptionalExists for rusqlite::Result<()> { } } -/// The unified migration lifts the supported schema version to (at least) 3; -/// V004 (DIP-13 invitations) has since raised the crate's actual max to 4. +/// The unified migration is embedded and supported. The exact ceiling moves +/// with the newest migration and is pinned in that migration's own test file. #[test] -fn max_supported_version_is_at_least_three() { +fn v003_is_embedded_and_supported() { assert!( - mig::max_supported_version() >= 3, - "V003 must raise max_supported_version to at least 3, got {}", - mig::max_supported_version() + mig::embedded_migrations().iter().any(|(v, _)| *v == 3), + "V003 must be in the embedded migration set" ); + assert!(mig::max_supported_version() >= 3, "V003 must be applicable"); } -/// TC-B-030 — a fresh store migrates clean through the unified V003 target -/// (and beyond, to V004's DIP-13 invitations table) and every V003 table -/// exists. +/// TC-B-030 — a fresh store applies V003 and migrates clean through to the +/// newest embedded migration (e.g. V004's DIP-13 invitations table), and +/// every V003 table exists. #[test] fn tc_b_030_fresh_store_migrates_to_version_three() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); + let applied: i64 = conn + .query_row( + "SELECT COUNT(*) FROM refinery_schema_history WHERE version = 3", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(applied, 1, "a fresh store must apply V003"); let max: i64 = conn .query_row( "SELECT MAX(version) FROM refinery_schema_history", @@ -76,7 +84,11 @@ fn tc_b_030_fresh_store_migrates_to_version_three() { |r| r.get(0), ) .unwrap(); - assert_eq!(max, 4, "fresh store must land at schema version 4"); + assert_eq!( + max, + mig::max_supported_version(), + "fresh store must land at the newest embedded schema version" + ); for table in [ "core_address_pool", "meta_data_versions", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs index d1852cb729..f6b3afeea1 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs @@ -22,7 +22,8 @@ use platform_wallet::changeset::{ ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, PendingContactCrypto, PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, - PlatformWalletPersistence, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; use platform_wallet::wallet::platform_wallet::WalletId; @@ -65,6 +66,28 @@ fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { ).unwrap()).unwrap() } +/// A BLS operator-key account entry — the provider half of the +/// account-registrations domain. +fn provider_operator_entry() -> ProviderKeyAccountEntry { + let wallet = Wallet::from_seed_bytes( + [0x2A; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: ProviderKeyExtendedPubKey::Bls( + wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS operator account") + .bls_public_key + .clone(), + ), + } +} + /// A changeset that touches exactly one domain, with minimal non-empty data. /// DB-validity is irrelevant here — `touched_domains` is a pure function. fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { @@ -168,6 +191,9 @@ fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { account_type: std_account(), account_xpub: test_xpub(), }]; + // Provider key-material accounts share this domain — both halves + // land in `account_registrations` rows (dashpay/platform#4113). + cs.provider_key_account_registrations = vec![provider_operator_entry()]; } Domain::AccountAddressPools => { cs.account_address_pools = vec![AccountAddressPoolEntry { @@ -187,7 +213,9 @@ fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { Domain::Invitations => { use dashcore::hashes::Hash; use dashcore::{OutPoint, Txid}; - use platform_wallet::changeset::{InvitationChangeSet, InvitationEntry, InvitationStatus}; + use platform_wallet::changeset::{ + InvitationChangeSet, InvitationEntry, InvitationStatus, + }; let op = OutPoint { txid: Txid::from_byte_array([0x0C; 32]), vout: 0, @@ -313,6 +341,11 @@ fn asset_lock_changeset() -> AssetLockChangeSet { #[test] fn tc_b_013_every_domain_maps_and_isolates() { use std::collections::BTreeSet; + assert_eq!( + Domain::ALL.len(), + 14, + "provider-key registrations ride the account-registrations domain — no new domain" + ); let mut covered = BTreeSet::new(); for domain in Domain::ALL { let cs = single_domain_changeset(domain); diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 3b5fc095b7..ab89d49387 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1197,12 +1197,9 @@ pub struct ProviderPlatformNodePubKey { /// so they never enter the [`Self::account_xpub`](AccountRegistrationEntry) /// snapshot the ECDSA accounts ride. Carried on /// [`PlatformWalletChangeSet`] as -/// `Vec`; the FFI layer bincode-encodes the -/// [`extended_public_key`](Self::extended_public_key) into the same -/// `AccountSpecFFI.account_xpub_bytes` slot the ECDSA accounts use (the -/// `type_tag` disambiguates the decode) and the restore side rebuilds a -/// watch-only `BLSAccount` / `EdDSAAccount` from it. Append-only merge, -/// same as [`AccountRegistrationEntry`]. +/// `Vec`. Persistence backends use the account type +/// to identify the key curve and rebuild a watch-only `BLSAccount` or +/// `EdDSAAccount`. Append-only merge, same as [`AccountRegistrationEntry`]. #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ProviderKeyAccountEntry { @@ -1212,6 +1209,71 @@ pub struct ProviderKeyAccountEntry { pub extended_public_key: ProviderKeyExtendedPubKey, } +/// Why a provider key-material account could not be rebuilt into an +/// [`AccountCollection`](key_wallet::account::account_collection::AccountCollection). +#[cfg(any(feature = "bls", feature = "eddsa"))] +#[derive(Debug, thiserror::Error)] +pub enum ProviderAccountRebuildError { + /// The curve-specific account constructor rejected the key. + #[error("provider key account is invalid")] + Invalid(#[from] key_wallet::error::Error), + /// The collection refused the account — its `account_type` does not match + /// the curve (e.g. a BLS key offered as `ProviderPlatformKeys`). + #[error("account collection rejected the provider key account: {0}")] + Rejected(&'static str), +} + +/// Rebuild one provider key-material account (BLS operator / EdDSA platform +/// node) watch-only into `accounts`. +/// +/// Shared by every persistence backend's restore path: these accounts live in +/// dedicated `Option` slots that [`AccountCollection::insert`] rejects by +/// design, so the curve-specific constructors and inserters below are the only +/// way in, and both mint watch-only accounts. +/// +/// # Errors +/// +/// [`ProviderAccountRebuildError::Invalid`] if the constructor rejects the key; +/// [`ProviderAccountRebuildError::Rejected`] if `account_type` and the key's +/// curve disagree. +/// +/// [`AccountCollection::insert`]: key_wallet::account::account_collection::AccountCollection::insert +#[cfg(any(feature = "bls", feature = "eddsa"))] +pub fn rebuild_provider_key_account( + accounts: &mut key_wallet::account::account_collection::AccountCollection, + wallet_id: [u8; 32], + network: key_wallet::Network, + account_type: AccountType, + extended_public_key: &ProviderKeyExtendedPubKey, +) -> Result<(), ProviderAccountRebuildError> { + match extended_public_key { + #[cfg(feature = "bls")] + ProviderKeyExtendedPubKey::Bls(key) => { + let account = key_wallet::account::BLSAccount::new( + Some(wallet_id.to_vec()), + account_type, + key.clone(), + network, + )?; + accounts + .insert_bls_account(account) + .map_err(ProviderAccountRebuildError::Rejected) + } + #[cfg(feature = "eddsa")] + ProviderKeyExtendedPubKey::EdDSA(key) => { + let account = key_wallet::account::EdDSAAccount::new( + Some(wallet_id.to_vec()), + account_type, + key.clone(), + network, + )?; + accounts + .insert_eddsa_account(account) + .map_err(ProviderAccountRebuildError::Rejected) + } + } +} + /// Address-pool snapshot for one `(account_type, pool_type)` pair. /// /// Routed through the changeset rather than a dedicated trait method diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 2ddac5121d..094bde6487 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -24,6 +24,8 @@ pub mod shielded_changeset; pub mod shielded_sync_start_state; pub mod traits; +#[cfg(any(feature = "bls", feature = "eddsa"))] +pub use changeset::{rebuild_provider_key_account, ProviderAccountRebuildError}; pub use changeset::{ upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index 5b950013e2..6b4cdac486 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -226,7 +226,7 @@ mod masternode_withdrawal_tests { fn selects_the_matching_owner_hash160_key_over_decoys() { let owner_hash = [0x11u8; 20]; let other_hash = [0x22u8; 20]; - let keys = vec![ + let keys = [ // Right hash, wrong purpose. make_key( 0, @@ -265,7 +265,7 @@ mod masternode_withdrawal_tests { #[test] fn returns_none_when_no_owner_key_matches() { let owner_hash = [0x11u8; 20]; - let keys = vec![ + let keys = [ make_key( 0, Purpose::TRANSFER, diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 1584f7eb73..20f8bd358f 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -191,12 +191,12 @@ pub fn derive_platform_node_public_keys( /// a freshly-registered wallet and a restored one carry byte-identical /// pool entries. /// -/// A no-op when the wallet has no managed platform-node account or no -/// `AbsentHardened` pool on it. +/// A no-op when the wallet has no managed platform-node account. /// /// # Errors /// [`PlatformWalletError::KeyDerivation`] if the `ProviderPlatformKeys` -/// account derivation path or a hardened child index cannot be built. +/// account has no `AbsentHardened` pool or a hardened child index cannot be +/// built. #[cfg(feature = "eddsa")] pub fn populate_platform_node_pool( wallet_info: &mut key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, @@ -204,27 +204,14 @@ pub fn populate_platform_node_pool( network: key_wallet::Network, ) -> Result<(), PlatformWalletError> { use dashcore::hashes::Hash; - use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::AddressInfo; if keys.is_empty() { return Ok(()); } - let Some(account) = wallet_info.accounts.provider_platform_keys.as_mut() else { + if wallet_info.accounts.provider_platform_keys.is_none() { return Ok(()); - }; + } - let account_path = AccountType::ProviderPlatformKeys - .derivation_path(network) - .map_err(|e| { - PlatformWalletError::KeyDerivation(format!( - "failed to build ProviderPlatformKeys account path: {e:?}" - )) - })?; - let base_children: Vec = account_path.as_ref().to_vec(); - - let mut infos: Vec = Vec::with_capacity(keys.len()); for key in keys { // Trust the entry's node id — it was just derived from `public_key`. let payload = dashcore::address::Payload::PubkeyHash( @@ -232,49 +219,110 @@ pub fn populate_platform_node_pool( ); let address = dashcore::Address::new(network, payload); let script_pubkey = address.script_pubkey(); - let child = key_wallet::bip32::ChildNumber::from_hardened_idx(key.index).map_err(|e| { - PlatformWalletError::KeyDerivation(format!( - "failed to build hardened child index {}: {e:?}", - key.index - )) - })?; - let mut children = base_children.clone(); - children.push(child); - let path = key_wallet::bip32::DerivationPath::from(children); - infos.push(AddressInfo { + insert_platform_node_pool_entry( + wallet_info, + network, + key.index, address, script_pubkey, - public_key: Some(PublicKeyType::EdDSA(key.public_key.to_vec())), - index: key.index, - path, - used: false, - generated_at: 0, - used_at: None, - tx_count: 0, - total_received: 0, - total_sent: 0, - balance: 0, - label: None, - metadata: std::collections::BTreeMap::new(), - }); + key.public_key, + false, + ) + .map_err(|e| PlatformWalletError::KeyDerivation(e.to_string()))?; } + Ok(()) +} + +/// Errors while inserting a pre-derived platform-node key into its managed pool. +#[cfg(feature = "eddsa")] +#[derive(Debug, thiserror::Error)] +pub enum PlatformNodePoolError { + /// The provider-platform account path cannot be constructed for the network. + #[error("provider platform account derivation path is invalid")] + InvalidAccountPath { + #[source] + source: key_wallet::error::Error, + }, + /// The managed provider-platform account lacks its hardened-only pool. + #[error("provider platform account has no AbsentHardened pool")] + MissingHardenedPool, + /// The persisted or derived index cannot form a hardened child number. + #[error("platform-node index {index} cannot form a hardened child number")] + InvalidChildIndex { + index: u32, + #[source] + source: key_wallet::error::Error, + }, +} - // The platform-node pool is `AbsentHardened` (SLIP-10 hardened-only). - if let Some(pool) = account +/// Insert one pre-derived Ed25519 key into the managed platform-node pool. +/// +/// A wallet without a managed provider-platform account is left unchanged. +/// +/// # Errors +/// +/// Returns [`PlatformNodePoolError::MissingHardenedPool`] when the account +/// exists without its required `AbsentHardened` pool, +/// [`PlatformNodePoolError::InvalidAccountPath`] when its network-scoped path +/// cannot be constructed, or +/// [`PlatformNodePoolError::InvalidChildIndex`] when `index` is not a valid +/// hardened child number. +#[cfg(feature = "eddsa")] +pub fn insert_platform_node_pool_entry( + wallet_info: &mut key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + network: key_wallet::Network, + index: u32, + address: dashcore::Address, + script_pubkey: dashcore::ScriptBuf, + public_key: [u8; 32], + used: bool, +) -> Result<(), PlatformNodePoolError> { + use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::AddressInfo; + + let Some(account) = wallet_info.accounts.provider_platform_keys.as_mut() else { + return Ok(()); + }; + let account_path = AccountType::ProviderPlatformKeys + .derivation_path(network) + .map_err(|source| PlatformNodePoolError::InvalidAccountPath { source })?; + let pool = account .managed_account_type_mut() .address_pools_mut() .into_iter() - .find(|p| p.pool_type == AddressPoolType::AbsentHardened) - { - for info in infos { - let idx = info.index; - pool.address_index.insert(info.address.clone(), idx); - pool.script_pubkey_index - .insert(info.script_pubkey.clone(), idx); - pool.highest_generated = Some(pool.highest_generated.map_or(idx, |h| h.max(idx))); - pool.addresses.insert(idx, info); - } - } + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .ok_or(PlatformNodePoolError::MissingHardenedPool)?; + let child = key_wallet::bip32::ChildNumber::from_hardened_idx(index) + .map_err(key_wallet::error::Error::Bip32) + .map_err(|source| PlatformNodePoolError::InvalidChildIndex { index, source })?; + let mut children: Vec = account_path.as_ref().to_vec(); + children.push(child); + 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); Ok(()) } @@ -886,4 +934,81 @@ mod tests { "highest_generated must advance to the last populated index" ); } + + #[cfg(feature = "eddsa")] + #[test] + fn insert_used_platform_node_pool_entry_restores_used_bookkeeping() { + use dashcore::hashes::Hash; + use key_wallet::managed_account::address_pool::AddressPoolType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = seed_bearing_wallet(Network::Mainnet); + let key = derive_platform_node_public_keys(&wallet, Network::Mainnet, 8) + .expect("platform-node derivation") + .pop() + .expect("derived key"); + let payload = dashcore::address::Payload::PubkeyHash( + dashcore::PubkeyHash::from_byte_array(key.node_id), + ); + let address = dashcore::Address::new(Network::Mainnet, payload); + let script_pubkey = address.script_pubkey(); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); + + insert_platform_node_pool_entry( + &mut wallet_info, + Network::Mainnet, + key.index, + address, + script_pubkey, + key.public_key, + true, + ) + .expect("restore used platform-node row"); + + let account = wallet_info + .accounts + .provider_platform_keys + .as_ref() + .expect("managed platform-node account"); + let pool = account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .cloned() + .expect("AbsentHardened pool"); + let restored = pool.addresses.get(&key.index).expect("restored entry"); + assert!(restored.used); + assert_eq!(restored.used_at, Some(0)); + assert!(pool.used_indices.contains(&key.index)); + assert_eq!(pool.highest_used, Some(key.index)); + } + + #[cfg(feature = "eddsa")] + #[test] + fn populate_platform_node_pool_rejects_missing_hardened_pool() { + use key_wallet::managed_account::address_pool::AddressPoolType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = seed_bearing_wallet(Network::Mainnet); + let keys = derive_platform_node_public_keys(&wallet, Network::Mainnet, 1) + .expect("platform-node derivation"); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = info + .accounts + .provider_platform_keys + .as_mut() + .expect("managed platform-node account must exist"); + for pool in account.managed_account_type_mut().address_pools_mut() { + pool.pool_type = AddressPoolType::Absent; + } + + let result = populate_platform_node_pool(&mut info, &keys, Network::Mainnet); + assert!( + result.is_err(), + "a platform-node account without its hardened pool must be rejected" + ); + } }