From 59dc06d0e22beb3507f49d6834a229a04a82f042 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Thu, 5 Nov 2026 16:54:58 +0000
Subject: [PATCH 1/2] fix(wallet-storage)!: reconcile historical AssetUnlock
identities
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adopt canonical history records while retaining validated legacy identity
evidence in the immutable archive and preserving physical metadata anchors.
Resolve historical prevouts for accounting without rewriting signed inputs,
coins, claims or proof keys. Keep physical backend reads separate from the
public alias projection and roll back incompatible confirmed contexts.
Cover migration and redelivery with old-writer fixtures, including a real
SIGHASH_ALL consumer, plus alias families, rollback and Recovery policy.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
---
.../src/sqlite/error.rs | 18 +-
.../src/sqlite/load_ctx.rs | 6 +
.../src/sqlite/persister.rs | 14 +-
.../src/sqlite/schema/core_history.rs | 153 +++-
.../src/sqlite/schema/core_history_aliases.rs | 370 ++++++++
.../src/sqlite/schema/core_state.rs | 4 +-
.../src/sqlite/schema/mod.rs | 1 +
.../fixtures/v019_asset_unlock_v2_signed.txt | 2 +
.../tests/integration/main.rs | 1 +
.../integration/sqlite_history_aliases.rs | 828 ++++++++++++++++++
.../src/changeset/changeset.rs | 19 +-
.../rs-platform-wallet/src/changeset/mod.rs | 21 +-
12 files changed, 1384 insertions(+), 53 deletions(-)
create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs
create mode 100644 packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt
create mode 100644 packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs
index 029476658ed..ade3e9489b6 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs
@@ -422,6 +422,16 @@ pub enum WalletStorageError {
blob_height: Option,
},
+ /// Aliases of one canonical transaction disagree about their confirmed block.
+ #[error(
+ "history aliases for {txid} in wallet {} have conflicting contexts",
+ hex::encode(wallet_id)
+ )]
+ HistoryAliasContextConflict {
+ wallet_id: [u8; 32],
+ txid: dashcore::Txid,
+ },
+
/// An incoming transaction record reuses a stored txid with a body whose
/// txid-committed content differs (witness-only differences are not a
/// conflict); neither copy is trusted to replace the other.
@@ -790,6 +800,7 @@ impl WalletStorageError {
| Self::AssetLockEntryMismatch { .. }
| Self::AssetLockStatusMismatch { .. }
| Self::CoreTransactionEntryMismatch { .. }
+ | Self::HistoryAliasContextConflict { .. }
| Self::TransactionBodyConflict { .. }
| Self::UnknownWalletNetwork { .. }
| Self::NetAmountOverflow { .. }
@@ -840,9 +851,9 @@ impl WalletStorageError {
Self::IdentityKeyWalletMismatch { .. } => PersistenceErrorKind::Constraint,
// History invariants checked in Rust on the write path: the incoming
// record contradicts stored history, so the data is wrong, not the engine.
- Self::TransactionBodyConflict { .. } | Self::NetAmountOverflow { .. } => {
- PersistenceErrorKind::Constraint
- }
+ Self::HistoryAliasContextConflict { .. }
+ | Self::TransactionBodyConflict { .. }
+ | Self::NetAmountOverflow { .. } => PersistenceErrorKind::Constraint,
// Refinery surfaces FK / constraint problems through rusqlite;
// if that path leaks through here the typed variant lives in
// `Self::Migration`, which we leave as `Fatal` since a
@@ -984,6 +995,7 @@ impl WalletStorageError {
Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch",
Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch",
Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch",
+ Self::HistoryAliasContextConflict { .. } => "history_alias_context_conflict",
Self::TransactionBodyConflict { .. } => "transaction_body_conflict",
Self::UnknownWalletNetwork { .. } => "unknown_wallet_network",
Self::NetAmountOverflow { .. } => "net_amount_overflow",
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs
index 025b15007ef..f37ae520a5f 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs
@@ -329,6 +329,12 @@ impl LoadCtx {
}
}
+ /// Recheck an already-counted snapshot under the same policy without
+ /// adding its inconsistencies to the load's original tally again.
+ pub(crate) fn for_recheck(&self) -> Self {
+ Self::new(self.policy)
+ }
+
/// Context that aborts the load on any inconsistency. A production
/// load takes its policy from the config; this is the shorthand for a
/// caller driving a reader directly.
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
index 0a20a39d8ab..3eea6cc4768 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
@@ -1619,13 +1619,17 @@ impl PlatformWalletPersistence for SqlitePersister {
Option,
PersistenceError,
> {
- let conn = self.conn().map_err(PersistenceError::from)?;
+ let mut conn = self.conn().map_err(PersistenceError::from)?;
// Tally deliberately dropped — see `last_load_degradation`. The
// policy still decides whether drift is fatal, and `tolerate` still
// logs it.
let ctx = LoadCtx::new(self.config.load_policy);
- schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx)
- .map_err(PersistenceError::from)
+ // Alias evidence and its active canonical record must belong to one
+ // SQLite snapshot, including when another connection writes the file.
+ let tx = conn.transaction().map_err(WalletStorageError::from)?;
+ let record = schema::core_history_aliases::get_record(&tx, &wallet_id, txid, &ctx)?;
+ tx.commit().map_err(WalletStorageError::from)?;
+ Ok(record)
}
/// Served from the `dpns_name_states` table this persister already
@@ -1719,9 +1723,11 @@ fn load_wallet_snapshot(
let account_manifest =
schema::accounts::load_state(conn, &wallet_id, ctx).map_err(PersistenceError::from)?;
- let (core_state, utxo_accounts) =
+ let (mut core_state, utxo_accounts) =
schema::core_state::load_state(conn, &wallet_id, network, ctx)
.map_err(PersistenceError::from)?;
+ schema::core_history_aliases::project_records(conn, &wallet_id, &mut core_state.records, ctx)
+ .map_err(PersistenceError::from)?;
// Pre-keyed rehydration: each `ManagedIdentity` leaves the loader
// already carrying its own public keys + contact state (matching
// the FFI persister), so signing works immediately post-load
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs
index 5269eeff9b8..d2d5b0e78f9 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs
@@ -13,7 +13,7 @@ use platform_wallet::wallet::platform_wallet::WalletId;
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use super::accounts::DASHPAY_EXTERNAL_LABEL;
-use super::{blob, core_state, wallets};
+use super::{blob, core_history_aliases, core_state, wallets};
use crate::sqlite::error::WalletStorageError;
use crate::sqlite::load_ctx::LoadCtx;
use crate::sqlite::util::safe_cast::i64_to_u64;
@@ -24,10 +24,20 @@ pub(super) fn preserve_known_details(
wallet_id: &WalletId,
incoming: &TransactionRecord,
) -> Result {
- let mut merged = incoming.clone();
+ let merged = incoming.clone();
let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else {
return Ok(merged);
};
+ merge_known_details(wallet_id, &previous, incoming)
+}
+
+/// Merge accounting evidence without assigning identity or finality policy.
+pub(super) fn merge_known_details(
+ wallet_id: &WalletId,
+ previous: &TransactionRecord,
+ incoming: &TransactionRecord,
+) -> Result {
+ let mut merged = incoming.clone();
// Compare only txid-committed content: a peer may attach BIP144 witnesses
// to a known txid, and that must neither conflict nor replace the stored body.
if previous.transaction.txid() != incoming.transaction.txid() {
@@ -36,10 +46,11 @@ pub(super) fn preserve_known_details(
txid: incoming.txid,
});
}
- merged.transaction = previous.transaction;
+ merged.transaction = previous.transaction.clone();
let mut inputs: BTreeMap<_, _> = previous
.input_details
- .into_iter()
+ .iter()
+ .cloned()
.map(|d| (d.index, d))
.collect();
for detail in &incoming.input_details {
@@ -47,7 +58,8 @@ pub(super) fn preserve_known_details(
}
let mut outputs: BTreeMap<_, _> = previous
.output_details
- .into_iter()
+ .iter()
+ .cloned()
.map(|d| (d.index, d))
.collect();
for detail in &incoming.output_details {
@@ -103,15 +115,39 @@ pub(crate) fn apply(
let mut consumers = tx.prepare_cached(
"SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2",
)?;
- for utxo in cs.new_utxos.iter().chain(&cs.spent_utxos) {
- affected.insert(utxo.outpoint.txid);
- let mut rows = consumers.query(params![
- wallet_id.as_slice(),
- blob::encode_outpoint(&utxo.outpoint)?
- ])?;
- while let Some(row) = rows.next()? {
- let bytes: Vec = row.get(0)?;
- affected.insert(Txid::from_slice(&bytes)?);
+ let mut funding: Vec<_> = cs
+ .new_utxos
+ .iter()
+ .chain(&cs.spent_utxos)
+ .map(|utxo| utxo.outpoint)
+ .collect();
+ // Adoption can connect already-stored coins to consumers without a new
+ // coin delivery. Ordinary records keep their existing scheduling policy.
+ for record in cs
+ .records
+ .iter()
+ .filter(|record| core_history_aliases::is_unlock(record))
+ {
+ funding.extend(
+ record
+ .transaction
+ .output
+ .iter()
+ .enumerate()
+ .map(|(index, _)| OutPoint::new(record.txid, index as u32)),
+ );
+ }
+ for changed in funding {
+ for outpoint in core_history_aliases::outpoints(tx, wallet_id, &changed)? {
+ affected.insert(outpoint.txid);
+ let mut rows = consumers.query(params![
+ wallet_id.as_slice(),
+ blob::encode_outpoint(&outpoint)?
+ ])?;
+ while let Some(row) = rows.next()? {
+ let bytes: Vec = row.get(0)?;
+ affected.insert(Txid::from_slice(&bytes)?);
+ }
}
}
if affected.is_empty() {
@@ -144,6 +180,46 @@ fn owned_output(
wallet_id: &WalletId,
outpoint: &OutPoint,
network: dashcore::Network,
+) -> Result