Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions lib/features/account/screens/account_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:mostro/core/app_routes.dart';
import 'package:mostro/core/app_theme.dart';
import 'package:mostro/core/services/identity_service.dart';
import 'package:mostro/features/account/providers/backup_reminder_provider.dart';
import 'package:mostro/features/trades/providers/trades_providers.dart';
import 'package:mostro/features/account/providers/privacy_mode_provider.dart';
import 'package:mostro/features/account/widgets/backup_trigger_sheet.dart';
import 'package:mostro/l10n/app_localizations.dart';
Expand Down Expand Up @@ -386,6 +387,12 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
// where the user is left without a valid identity.
await IdentityService.regenerate();
ref.read(sessionProvider.notifier).clearSession();
// The new identity starts with an empty trade DB (the Rust
// side clears trades, messages and sessions on regenerate,
// issue #273); drop the cached list so My Trades reflects
// the clean slate immediately instead of showing the
// previous identity's orders until the next refresh.
ref.invalidate(rawTradesProvider);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await ref
.read(backupReminderProvider.notifier)
.showBackupReminder();
Expand Down
29 changes: 29 additions & 0 deletions rust/src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,35 @@ pub async fn delete_identity() -> Result<()> {
if let Err(e) = db.clear_trade_keys().await {
log::warn!("[identity] failed to clear trade key mappings: {e}");
}
// Messages before trades: messages.trade_id is an FK onto trades(id).
// Both belong to the deleted identity — their trade keys were just
// cleared, so the rows are dead state a new identity must not inherit
// (privacy: a fresh user must not see the previous one's My Trades or
// chats). See issue #273.
//
// Unlike the identity row and trade-key mappings above (which have the
// `reconcile_trade_key_index` pubkey guard as a fallback), the trades
// and messages tables are NOT identity-scoped and have no such guard:
// a silent failure here would leak the previous identity's history into
// the next one. Propagate the error so the caller (regenerate /
// importAndStore) aborts before creating the replacement identity —
// delete_identity runs before the new identity exists, so there is no
// half-rotated state to unwind.
db.clear_messages()
.await
.map_err(|e| anyhow!("failed to clear messages on identity deletion: {e}"))?;
db.clear_trades()
.await
.map_err(|e| anyhow!("failed to clear trades on identity deletion: {e}"))?;
}

// Empty the in-memory sessions too: they key decryption for the deleted
// identity's trades and must not carry into the new one.
let dropped = crate::mostro::session::session_manager().clear_all().await;
if dropped > 0 {
log::debug!(
"[identity] cleared {dropped} in-memory session(s) on identity deletion"
);
}

// Last, so the cleanup warnings above are dropped too: buffered lines name
Expand Down
19 changes: 19 additions & 0 deletions rust/src/db/indexeddb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,25 @@ impl Storage for IndexedDbStorage {
Ok(()) // IndexedDB not yet implemented (#233)
}

async fn clear_trades(&self) -> Result<()> {
Ok(()) // No trades store on web yet (#233); nothing to clear.
}

async fn clear_messages(&self) -> Result<()> {
// Messages ARE persisted on web (save_message writes MESSAGES_STORE),
// so identity deletion must actually wipe them, not no-op (#273).
let db = self.open_db().await?;
let tx = db
.transaction_on_one_with_mode(MESSAGES_STORE, IdbTransactionMode::Readwrite)
.map_err(|e| js_err("tx open", e))?;
let store = tx
.object_store(MESSAGES_STORE)
.map_err(|e| js_err("store open", e))?;
store.clear().map_err(|e| js_err("clear", e))?;
tx.await.into_result().map_err(|e| js_err("tx commit", e))?;
Ok(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── Settings KV — fully implemented (chat cursor + preferences, #246) ───

async fn get_setting(&self, key: &str) -> Result<Option<String>> {
Expand Down
10 changes: 10 additions & 0 deletions rust/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@ pub trait Storage: Send + Sync {
/// order→index mappings belong to the removed identity's derivation tree.
async fn clear_trade_keys(&self) -> Result<()>;

/// Delete ALL trade rows. Used on identity deletion: the recovered trade
/// history belongs to the removed identity, and its trade keys are cleared
/// alongside, so the rows are dead state a new identity must not inherit.
async fn clear_trades(&self) -> Result<()>;

/// Delete ALL chat message rows. Used on identity deletion: the peer and
/// admin conversations belong to the removed identity and must not leak
/// into the fresh one.
async fn clear_messages(&self) -> Result<()>;

// ── Settings / Mostro node ────────────────────────────────────────────────

/// Read a value from the generic key-value settings store, or `None` when
Expand Down
65 changes: 65 additions & 0 deletions rust/src/db/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,20 @@ impl Storage for SqliteStorage {
Ok(())
}

async fn clear_trades(&self) -> Result<()> {
sqlx::query("DELETE FROM trades")
.execute(&self.pool)
.await?;
Ok(())
}

async fn clear_messages(&self) -> Result<()> {
sqlx::query("DELETE FROM messages")
.execute(&self.pool)
.await?;
Ok(())
}

async fn get_setting(&self, key: &str) -> Result<Option<String>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM settings WHERE key = ?")
Expand Down Expand Up @@ -1064,4 +1078,55 @@ mod tests {
drop(storage);
let _ = std::fs::remove_file(&path);
}

#[tokio::test]
async fn clear_trades_and_messages_empty_both_tables() {
// Identity deletion must wipe the previous user's trade history and
// chats (privacy, issue #273), not just the keys. Insert one trade and
// one message, clear both tables, and confirm nothing survives.
let path = temp_db_path();
let storage = SqliteStorage::open(path.to_str().unwrap()).await.unwrap();
sqlx::query("INSERT INTO trades VALUES ('t1', '{}', 'Active', 1, NULL)")
.execute(&storage.pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO messages (id, trade_id, data, is_read, created_at) \
VALUES ('m1', 't1', '{}', 0, 1)",
)
.execute(&storage.pool)
.await
.unwrap();

// Precondition: one row in each table.
let trades: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM trades")
.fetch_one(&storage.pool)
.await
.unwrap();
assert_eq!(trades.0, 1);
assert!(storage.message_exists("m1").await.unwrap());

storage.clear_messages().await.unwrap();
storage.clear_trades().await.unwrap();

// Both tables are empty afterwards.
let trades: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM trades")
.fetch_one(&storage.pool)
.await
.unwrap();
let messages: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM messages")
.fetch_one(&storage.pool)
.await
.unwrap();
assert_eq!(trades.0, 0);
assert_eq!(messages.0, 0);
assert!(!storage.message_exists("m1").await.unwrap());

// Clearing again on empty tables is a no-op, not an error.
storage.clear_messages().await.unwrap();
storage.clear_trades().await.unwrap();

drop(storage);
let _ = std::fs::remove_file(&path);
}
}
10 changes: 10 additions & 0 deletions rust/src/mostro/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ impl SessionManager {
});
before - sessions.len()
}

/// Drop every in-memory session. Used on identity deletion: the sessions
/// belong to the removed identity's trades, and a new identity must not
/// inherit them. Returns the number of sessions dropped.
pub async fn clear_all(&self) -> usize {
let mut sessions = self.sessions.write().await;
let dropped = sessions.len();
sessions.clear();
dropped
}
}

// ── Global singleton ────────────────────────────────────────────────────────
Expand Down
25 changes: 25 additions & 0 deletions test/features/trades/filtered_trades_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,29 @@ void main() {
});
});
});

group('rawTradesProvider invalidation (regenerate clean slate)', () {
test('invalidating after the DB is cleared yields an empty list', () async {
// The account screen calls ref.invalidate(rawTradesProvider) after
// IdentityService.regenerate(); the Rust side has cleared the trades DB
// by then (issue #273). Model that: a fetcher that first returns the old
// identity's trades, then — once "cleared" — returns nothing.
var trades = <TradeInfo>[
fakeTrade(id: 'old', status: OrderStatus.active),
];
final container = createContainer(overrides: [
rawTradesProvider.overrideWith((ref) async => trades),
]);

// Before regenerate: My Trades shows the previous identity's trades.
expect(await container.read(rawTradesProvider.future), isNotEmpty);

// delete_identity() cleared the DB; the account screen invalidates.
trades = <TradeInfo>[];
container.invalidate(rawTradesProvider);

// After: the cached list is dropped and the empty DB is reflected.
expect(await container.read(rawTradesProvider.future), isEmpty);
});
});
}
Loading