Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
50cab4c
test(platform-wallet-storage): capture populated-V001 fixture (WS-B B0)
lklimek Jul 2, 2026
61c292c
feat(platform-wallet-storage): add V002 unified migration (WS-B B1)
lklimek Jul 2, 2026
c9603d6
chore(platform-wallet-storage): drop committed WAL side-files, gitign…
lklimek Jul 2, 2026
e663a35
test(platform-wallet-storage): pin schema content + freeze retired na…
lklimek Jul 2, 2026
503b251
feat(platform-wallet-storage): real account_index + pool-row writer (…
lklimek Jul 2, 2026
f06f9ff
feat(platform-wallet-storage): bump meta_data_versions inside the flu…
lklimek Jul 2, 2026
00499e0
feat(platform-wallet-storage): rotate store-generation on restore (WS…
lklimek Jul 2, 2026
d514d01
feat(platform-wallet-storage): read used-set verbatim from pool rows …
lklimek Jul 2, 2026
c44d684
docs(platform-wallet): correct account_address_pools storage semantic…
lklimek Jul 2, 2026
1d2612e
test(platform-wallet-storage): migration execution against V001 fixtu…
lklimek Jul 2, 2026
a7c1aa6
chore(platform-wallet-storage): QA pass — clippy/secrets/prepared-stm…
lklimek Jul 2, 2026
6dd08d4
fix(platform-wallet-storage): re-arm exhaustive touched_domains guard…
lklimek Jul 2, 2026
ab12991
fix(platform-wallet-storage): union pool + utxo used-sets in reader (…
lklimek Jul 2, 2026
4f95f46
test(platform-wallet-storage): prove interrupted-migration recovery (…
lklimek Jul 2, 2026
e3eb645
test(platform-wallet-storage): cross-wallet cascade/isolation for V00…
lklimek Jul 2, 2026
a511bde
docs(platform-wallet-storage): drop ephemeral plan-task IDs from test…
lklimek Jul 2, 2026
0614fc4
Merge branch 'feat/platform-wallet-storage-rehydration' into feat/396…
lklimek Jul 3, 2026
d22d5c5
fix(platform-wallet-storage): populate core_wallet_info in SQLite reh…
lklimek Jul 3, 2026
aa11d17
fix(platform-wallet-storage): harden core address-pool storage
lklimek Jul 3, 2026
c0f17da
fix(platform-wallet-storage): rotate store generation within restore …
lklimek Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,9 @@ kv = ["sqlite"]
__test-helpers = ["sqlite"]
# e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool).
rehydration-apply = []
# Pass-through to `platform-wallet/shielded` so the feature-gated
# `PlatformWalletChangeSet::shielded` field is visible to the exhaustive
# `versions::touched_domains` destructure (the R8 forgotten-domain guard).
# Storage persists no shielded state itself; this only aligns visibility so
# an added always-on field stays a compile error.
shielded = ["platform-wallet/shielded"]
80 changes: 80 additions & 0 deletions packages/rs-platform-wallet-storage/migrations/V002__unified.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! Unified additive migration for `platform-wallet-storage` (#3968).
//!
//! Additive-only: V001 stays byte-identical so refinery's applied-migration
//! checksum for version 1 never diverges on an existing store. V002 lifts
//! `max_supported_version()` from 1 to 2 automatically (the value is derived
//! from the embedded list) and lands three concerns in one migration event:
//!
//! - `core_address_pool` — per-index address-pool rows with a `used` flag,
//! the first-class row store that replaces `core_utxos` script-derivation
//! for the address-reuse guard. `account_type` and `pool_type` are both in
//! the primary key: `account_type` so two accounts that collapse to the same
//! `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` and
//! `ProviderVotingKeys`, both `0, 0`) never overwrite each other, and
//! `pool_type` so an External (receive) and Internal (change) pool never
//! collide at the same `address_index`. `script` (the address'
//! `script_pubkey`) is stored so the reader returns used addresses verbatim
//! and the UTXO writer can attribute an outpoint to its owning account, both
//! without re-deriving.
//! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq`
//! bumped inside the flush transaction, the cache-invalidation keystone.
//! No FK (a domain row may be written before its typed parent syncs,
//! mirroring the `meta_*` tables); a soft-cascade trigger reaps rows on
//! wallet delete.
//! - `meta_store_generation` — a single-row store-generation token,
//! initialized with `randomblob(16)` so the rendered SQL stays deterministic (the
//! content fingerprint pins the text, the runtime value is unique per
//! store). Regenerated on restore.
//!
//! No MAC column ships here — manifest authentication is deferred out of
//! this workstream (dev-plan §7).

pub fn migration() -> String {
"\
CREATE TABLE core_address_pool (
wallet_id BLOB NOT NULL,
account_type TEXT NOT NULL,
account_index INTEGER NOT NULL,
key_class INTEGER NOT NULL DEFAULT 0,
pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)),
address_index INTEGER NOT NULL,
script BLOB NOT NULL,
used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)),
PRIMARY KEY (wallet_id, account_type, account_index, key_class, pool_type, address_index),
FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
Comment on lines +34 to +45

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: core_address_pool PK omits account_type — silent cross-account row collisions in the common case

The core_address_pool PK is (wallet_id, account_index, key_class, pool_type, address_index). Verified against accounts.rs:273-303:

  • account_key_class() returns the real key class only for PlatformPayment — every other variant maps to the sentinel 0.
  • account_index() collapses at least nine distinct variants (IdentityRegistration, IdentityTopUpNotBoundToIdentity, IdentityInvitation, AssetLockAddressTopUp, AssetLockShieldedAddressTopUp, all four Provider*Keys) to 0.

So the following distinct accounts, all created concurrently in a normal wallet, upsert onto the same PK tuple in core_address_pool:

  • Standard{index:0, BIP44} and CoinJoin{index:0} collide on (wallet_id, 0, 0, {0,1}, address_index) (both derive External/Internal pools).
  • IdentityRegistration, IdentityInvitation, ProviderVotingKeys, ProviderOwnerKeys, ProviderOperatorKeys, etc. — all AddressPoolType::Absent at address_index=0 — collide on (wallet_id, 0, 0, 2, 0).

UPSERT_POOL_SQL at core_pool.rs:31-36 (ON CONFLICT ... DO UPDATE SET script = excluded.script, used = MAX(used, excluded.used)) silently overwrites the earlier account's script with the last one applied and merges used flags from unrelated addresses. This breaks both the "verbatim snapshot" guarantee this PR advertises and the address-reuse guard (a used bit can bleed onto an unrelated address once a colliding pool row's script wins the last-writer race).

This is a direct regression relative to account_registrations, whose PK explicitly widens with account_type TEXT specifically to disambiguate these variants (see the apply_registrations insert at accounts.rs:122-127 and the comment at 117-120: "distinct accounts that share (account_type, account_index) don't overwrite each other"). The pattern for correct disambiguation was already in the codebase but wasn't applied to the new table.

Test gap: none of the new tests (sqlite_core_pool_writer.rs, sqlite_pool_reader.rs, sqlite_v002_isolation.rs, sqlite_version_bump.rs) exercise more than one account type sharing (account_index, key_class) in the same wallet — only Standard and PlatformPayment are used, and never together — so the collision is invisible to CI.

Fix: widen the PK with an account_type discriminator (mirroring account_registrations), and add a regression test that flushes two different AccountType variants sharing (0, 0) (e.g. IdentityRegistration + ProviderVotingKeys, and Standard{0} + CoinJoin{0}) into the same wallet and asserts both survive with correct scripts and used flags.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and agreed — blocking. Verified account_index/account_key_class collapse ≥9 AccountType variants to (0, 0), and UPSERT_POOL_SQL's ON CONFLICT genuinely overwrites script/merges used across unrelated accounts. Also checked: this does NOT recur elsewhere in the schema — account_registrations already disambiguates correctly via account_type in its PK; core_utxos/asset_locks/platform_addresses key on real UTXO/address identity with account_index as attribute-only. Isolated to this one new table. Needs a fix before merge — not yet fixed.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: my automated reconciler posted an erroneous "resolved" reply here while processing the 0614fc4 review. This finding is still valid at 0614fc4 and was carried forward in the current review.

The misleading resolved text came from using the live PR diff after the branch had already advanced to a newer commit. Please ignore the earlier resolved wording in this reply thread.

Comment on lines +34 to +45

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: core_address_pool PK omits DashPay (user_identity_id, friend_identity_id) — schema forethought while the migration is still landing

The widened PK (wallet_id, account_type, account_index, key_class, pool_type, address_index) correctly closes the account_type-variant collisions the prior review flagged, verified by distinct_account_types_sharing_index_zero_do_not_collide and standard_and_coinjoin_index_zero_do_not_collide in sqlite_core_pool_writer.rs.

However, account_type_db_label collapses every DashpayReceivingFunds{index, user_identity_id, friend_identity_id} to the fixed string "dashpay_receiving" (accounts.rs:265) and account_index() returns just the inner index field (accounts.rs:288). send_contact_request (contact_requests.rs:162) hardcodes account_index = 0 for every DashPay contact, so two contacts on the same wallet would collapse onto the identical PK tuple (wallet_id, 'dashpay_receiving', 0, 0, pool_type, address_index) and silently overwrite each other via ON CONFLICT ... DO UPDATE SET script = excluded.script (core_pool.rs:36-38).

Mitigation for THIS PR: verified in-tree that register_contact_account (contacts.rs:100-156) only inserts the managed account into in-memory info.core_wallet.accounts and never emits an AccountAddressPoolEntry to the persister — DashPay pool rows are not currently written to core_address_pool in production, so no live collision. The sibling account_registrations table (V001) does carry (user_identity_id, friend_identity_id) in its PK for exactly this reason, with a passing distinct_dashpay_friends_do_not_collide test, and account_dashpay_ids() (accounts.rs:309) already exposes the pair.

Since V002 is additive-only, widening the PK later requires another migration. If DashPay pool persistence is on the near-term roadmap for this workstream, mirror account_registrations' PK now (add user_identity_id/friend_identity_id columns, thread account_dashpay_ids through apply_pools/UPSERT_POOL_SQL/account_index_for_script, add a regression test with two DashpayReceivingFunds entries sharing (account_type, account_index, key_class) but differing only by contact identity). Otherwise, add a brief note to the V002 migration doc-comment explicitly deferring the DashPay identity axis (mirroring the manifest-MAC deferral note) and track it as a follow-up.

source: ['claude']


CREATE INDEX idx_core_address_pool_used
ON core_address_pool(wallet_id, used);

-- The UTXO writer attributes an outpoint to its owning account by matching
-- the outpoint's script against a pool row.
CREATE INDEX idx_core_address_pool_script
ON core_address_pool(wallet_id, script);

CREATE TABLE meta_data_versions (
wallet_id BLOB NOT NULL,
domain TEXT NOT NULL,
seq INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (wallet_id, domain)
);

-- Soft-cascade reap, matching the meta_* tables: no FK (a domain may be
-- bumped before its typed parent exists), so a trigger clears rows when
-- the owning wallet is deleted.
CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete
AFTER DELETE ON wallets
FOR EACH ROW
BEGIN
DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id;
END;

CREATE TABLE meta_store_generation (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0),
generation BLOB NOT NULL
);

INSERT INTO meta_store_generation (id, generation) VALUES (0, randomblob(16));
"
.to_string()
}
63 changes: 44 additions & 19 deletions packages/rs-platform-wallet-storage/src/sqlite/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,17 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> {
///
/// Validation runs against the source and again against the STAGED bytes,
/// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks
/// every other SQLite peer (which advisory flock could not). The staged
/// temp is `persist`-ed as an atomic rename only after all gates pass, and
/// that rename is the commit point: if it fails, the live DB and its WAL/SHM
/// siblings are left untouched, so a failed restore never strands the old DB
/// without its WAL-committed state. The now-stale WAL/SHM siblings are
/// unlinked only AFTER the swap succeeds (so a leftover `-wal` can't shadow
/// the restored DB); the parent dir is fsynced afterward. See the numbered
/// steps in the body for the per-phase rationale.
/// every other SQLite peer (which advisory flock could not). The
/// store-generation token is rotated INTO the staged temp before the swap,
/// so the single commit point brings in the restored bytes and the fresh
/// token together — a peer never observes restored content carrying the
/// source's stale token. The staged temp is `persist`-ed as an atomic rename
/// only after all gates pass, and that rename is the commit point: if it
/// fails, the live DB and its WAL/SHM siblings are left untouched, so a failed
/// restore never strands the old DB without its WAL-committed state. The
/// now-stale WAL/SHM siblings are unlinked only AFTER the swap succeeds (so a
/// leftover `-wal` can't shadow the restored DB); the parent dir is fsynced
/// afterward. See the numbered steps in the body for the per-phase rationale.
///
/// # Lock-release-before-rename trade-off
///
Expand Down Expand Up @@ -224,7 +227,27 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?;
}

// 5. chmod 0o600 on the temp BEFORE persist so the destination
// 5. Regenerate the store-generation token INTO the staged temp, before
// the atomic rename, so the single commit point (step 8) swaps in the
// restored bytes and the rotated token together — there is no window
// where restored content is observable with the source's stale token.
// The staged DB is switched to DELETE journaling first so the UPDATE
// lands in the main file with no `-wal` frames stranded outside the
// rename; the reopened destination is forced back to its configured
// journal mode on its next open. A pre-V002 backup has no generation
// table; `regenerate_generation` is a no-op there and the token is
// (re)seeded on its later migration to V002.
{
let conn =
crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?;
conn.pragma_update(None, "journal_mode", "DELETE")?;
crate::sqlite::schema::versions::regenerate_generation(&conn)?;
drop(conn);
// Durably flush the regenerated token before the rename commits it.
tmp.as_file().sync_all()?;
}

// 6. chmod 0o600 on the temp BEFORE persist so the destination
// inherits owner-only mode via the rename (post-persist chmod could
// fail with the new DB already live).
#[cfg(unix)]
Expand All @@ -234,7 +257,7 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}

// 6. Release the EXCLUSIVE lock before the rename/unlinks: on Windows /
// 7. Release the EXCLUSIVE lock before the rename/unlinks: on Windows /
// some FUSE mounts `remove_file` on a still-open file returns
// `PermissionDenied`, and the rename window wants a clean close (see
// lock-release trade-off above).
Expand All @@ -243,18 +266,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
drop(conn);
}

// 7. Persist the staged DB atomically over the destination FIRST. The
// atomic rename is the commit point: if it fails (disk full, EXDEV,
// perms) the live DB and its WAL/SHM siblings are left untouched, so a
// failed restore can never strand the old DB without its WAL-committed
// state. Sibling cleanup (step 8) runs only once the swap has succeeded.
// 8. Persist the staged DB atomically over the destination. The atomic
// rename is the single commit point: it swaps in both the restored
// bytes and the rotated generation token together. If it fails (disk
// full, EXDEV, perms) the live DB and its WAL/SHM siblings are left
// untouched, so a failed restore can never strand the old DB without
// its WAL-committed state. Sibling cleanup (step 9) runs only once the
// swap has succeeded.
tmp.persist(dest_db_path)
.map_err(|e| WalletStorageError::Io(e.error))?;

// 8. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover
// 9. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover
// `-wal` can't shadow the restored DB on the next open. Sibling paths
// use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is a
// silent no-op. The lock conn was dropped in step 6 for cross-platform
// silent no-op. The lock conn was dropped in step 7 for cross-platform
// unlink semantics.
if let Some(file_name) = dest_db_path.file_name() {
for ext in ["-wal", "-shm"] {
Expand All @@ -269,10 +294,10 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
}
}

// 9. Make the rename + unlink dentry updates durable.
// 10. Make the rename + unlink dentry updates durable.
fsync_parent_dir(dest_db_path)?;

// 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm).
// 11. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm).
apply_secure_permissions(dest_db_path)?;
Ok(())
}
Expand Down
46 changes: 46 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,52 @@ pub fn embedded_migrations_fingerprint() -> [u8; 32] {
hasher.finalize().into()
}

/// SHA-256 over `(version, name, rendered SQL)` of every embedded migration
/// in version order. Unlike [`embedded_migrations_fingerprint`] this is
/// content-level: it pins each migration's SQL body, so an in-place DDL edit
/// (e.g. renaming a table inside a same-named file) breaks the golden test.
/// This is the guard the D0 schema freeze relies on; the identity-only
/// fingerprint cannot catch a same-name body edit.
///
/// The SQL *text* is deterministic even where a value is generated at run
/// time (`randomblob(16)`): the literal string is hashed, not the runtime
/// bytes.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_sql_fingerprint() -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut migrations = migrations::runner().get_migrations().clone();
migrations.sort_by_key(|m| m.version());
let mut hasher = Sha256::new();
for m in &migrations {
hasher.update((m.version() as u32).to_be_bytes());
hasher.update([0u8]);
hasher.update(m.name().as_bytes());
hasher.update([0u8]);
let sql = m
.sql()
.expect("embedded migrations always carry rendered SQL");
hasher.update(sql.as_bytes());
hasher.update([0u8]);
}
hasher.finalize().into()
}

/// Rendered SQL of every embedded migration, in version order. Used by the
/// schema-freeze grep guard to scan for retired table names.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_sql() -> Vec<String> {
let mut migrations = migrations::runner().get_migrations().clone();
migrations.sort_by_key(|m| m.version());
migrations
.iter()
.map(|m| {
m.sql()
.expect("embedded migrations always carry rendered SQL")
.to_string()
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
66 changes: 36 additions & 30 deletions packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use rusqlite::{Connection, OptionalExtension};

use platform_wallet::changeset::{
ClientStartState, Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
};
use platform_wallet::wallet::platform_wallet::WalletId;

Expand Down Expand Up @@ -940,20 +940,37 @@ impl PlatformWalletPersistence for SqlitePersister {
.map_err(PersistenceError::from)?;
let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id)
.map_err(PersistenceError::from)?;
// Every address that ever held a UTXO (spent + unspent) is "used":
// the address-reuse guard so a used-then-emptied address is never
// handed back as a fresh receive address. The in-band pool snapshot
// was retired, so we derive this from the full core_utxos set.
let used_core_addresses =
schema::core_state::load_used_addresses(&conn, &wallet_id, network)
// Used addresses drive the reuse guard: a used-then-emptied
// address must never be handed back as a fresh receive address.
// Union the verbatim `core_address_pool` used-set with the
// `core_utxos`-derived set (spent + unspent). The guard is
// monotonic, so a mixed store — historical UTXOs plus a later
// partial pool snapshot that never enumerates them — must surface
// both; neither source may shadow the other. Deduped by script.
let used_core_addresses = {
let mut seen = std::collections::HashSet::new();
let mut union = Vec::new();
let pool = schema::core_pool::load_used_addresses(&conn, &wallet_id, network)
.map_err(PersistenceError::from)?;
let utxo = schema::core_state::load_used_addresses(&conn, &wallet_id, network)
.map_err(PersistenceError::from)?;
for addr in pool.into_iter().chain(utxo) {
if seen.insert(addr.script_pubkey().to_bytes()) {
union.push(addr);
}
}
union
};

state.wallets.insert(
wallet_id,
platform_wallet::changeset::ClientWalletStartState {
network,
birth_height,
account_manifest,
// SQLite rehydration replays the keyless projection onto a
// fresh skeleton; it mints no full snapshot.
core_wallet_info: None,
core_state,
identity_manager,
unused_asset_locks,
Expand Down Expand Up @@ -994,25 +1011,9 @@ impl PlatformWalletPersistence for SqlitePersister {
/// from the public fields so no storage-only helper leaks into the
/// `rs-platform-wallet` API.
fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize {
[
cs.core.is_empty(),
cs.identities.is_empty(),
cs.identity_keys.is_empty(),
cs.contacts.is_empty(),
cs.platform_addresses.is_empty(),
cs.asset_locks.is_empty(),
cs.token_balances.is_empty(),
cs.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()),
cs.dashpay_payments_overlay
.as_ref()
.is_none_or(|m| m.is_empty()),
cs.wallet_metadata.is_none(),
cs.account_registrations.is_empty(),
cs.account_address_pools.is_empty(),
]
.iter()
.filter(|empty| !**empty)
.count()
// Single source of truth with the version-domain mapping: each populated
// field is exactly one touched domain.
schema::versions::touched_domains(cs).len()
}

fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> {
Expand Down Expand Up @@ -1088,10 +1089,12 @@ fn apply_changeset_to_tx(
if !cs.account_registrations.is_empty() {
schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?;
}
// `account_address_pools` is intentionally NOT applied: UTXO attribution
// is hardcoded to the default account (index 0) in `core_state`, so the
// pool snapshot is no longer a storage input. The changeset field is kept
// for API stability and still feeds non-storage consumers.
// 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.
if !cs.account_address_pools.is_empty() {
schema::core_pool::apply_pools(tx, wallet_id, &cs.account_address_pools)?;
}
if let Some(core) = cs.core.as_ref() {
schema::core_state::apply(tx, wallet_id, core)?;
}
Expand Down Expand Up @@ -1121,6 +1124,9 @@ fn apply_changeset_to_tx(
cs.dashpay_payments_overlay.as_ref(),
)?;
}
// Bump each touched domain's version inside this same tx so a domain's
// cache-invalidation marker commits atomically with its data.
schema::versions::bump_touched_domains(tx, wallet_id, cs)?;
Ok(())
}

Expand Down
Loading
Loading