diff --git a/Cargo.lock b/Cargo.lock index 6b7b6179ace..dbb010fee5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1654,7 +1654,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dash-network", ] @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "async-trait", "chrono", @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "anyhow", "base64-compat", @@ -1815,12 +1815,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dashcore-rpc-json", "hex", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dashcore", "grovedb-bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "dashcore-private", "grovedb-bincode", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" [[package]] name = "glob" @@ -4154,7 +4154,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "aes", "async-trait", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +source = "git+https://github.com/dashpay/rust-dashcore?rev=097ee74948fedd5928409be277cec1ab9930137d#097ee74948fedd5928409be277cec1ab9930137d" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index 305df7df4b2..2d40e087ae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,14 +65,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dce8252f8665bf06c7bd788e739efba354141690" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e4208c90786a6854bd498315bcb571ef24182c15" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "097ee74948fedd5928409be277cec1ab9930137d" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 769378695c1..c810d624d3a 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -42,7 +42,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 tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. The diagrams cover 21 of V001's 23 tables; `pending_contact_crypto` and `ignored_senders` appear in the [Tables](#tables) section but are not diagrammed. They show the V001 tables as amended in place by every later migration that changes one of them (the current `core_utxos`, `core_transactions`, `platform_addresses`, and `asset_locks` shapes). Nine tables added by later migrations are not yet diagrammed here: `core_address_pool`, `meta_data_versions`, and `meta_store_generation` (V009, plus its V010–V011 `core_address_pool` columns), `invitations` (V003), `shielded_viewing_keys` (V013), `dpns_name_states` (V005), `tracked_masternodes` (V006), and the `identity_scan_states` / `identity_scan_failed_indices` pair (V017) — see the [Migrations](#migrations) log for what each adds in the meantime. +The tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. The diagrams cover 21 of V001's 23 tables; `pending_contact_crypto` and `ignored_senders` appear in the [Tables](#tables) section but are not diagrammed. They show the V001 tables as amended in place by every later migration that changes one of them (the current `core_utxos`, `core_transactions`, `platform_addresses`, and `asset_locks` shapes). Eleven tables added by later migrations are not yet diagrammed here: `core_address_pool`, `meta_data_versions`, and `meta_store_generation` (V009, plus its V010–V011 `core_address_pool` columns), `invitations` (V003), `shielded_viewing_keys` (V013), `dpns_name_states` (V005), `tracked_masternodes` (V006), the `identity_scan_states` / `identity_scan_failed_indices` pair (V017), and the `core_transaction_inputs` / `core_transaction_record_originals` pair (V019) — see the [Migrations](#migrations) log for what each adds in the meantime. ## Diagram 1 — Core / L1 (Bitcoin/Dash layer) @@ -89,7 +89,7 @@ erDiagram BLOB script "scriptPubKey bytes" INTEGER is_sweep_placeholder "1 until funding arrives" INTEGER spent "0 | 1" - BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" + BLOB spent_in_txid "spender claiming the row: apply_sweep hold, runtime or V019 history repair; else NULL" INTEGER winner_mined_height "V007: sweep winner's mined height; NULL when unstamped or materialised" } @@ -402,13 +402,36 @@ One row per UTXO, spent or unspent. Owning-account identity is derived from `core_address_pool` while loading wallet state, and confirmation height is derived from `core_transactions.height`. -`spent_in_txid` is written only by -`apply_sweep`, naming the winner that took an input a swept loser claimed -but this store had no released record for. It is set to NULL by a trigger +`spent_in_txid` names the transaction that claims the row. Three writers set +it: + +- `apply_sweep`, naming the winner that took an input a swept loser claimed + but this store had no released record for; +- the runtime history repair (`schema::core_history`), which marks an owned + output `spent = 1` for each non-mempool stored record that spends it, + including a record stored before the output itself was known; +- the V019 migration repair (`migrations::legacy_v019`), which does the same + once over every stored record. + +Both repairs only fill the link: an existing claim (`spent = 1` with a +non-NULL `spent_in_txid`) stands, so a repair never overwrites another +spender. It is set to NULL by a trigger when its referenced `core_transactions` row is deleted (instead of a native `ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` column) — and by a later sweep that releases the same outpoint. +On load, recorded conflicts are reconciled wallet-wide using persisted +ChainLock and InstantSend finality. The loader applies the sweep results and +rebuilds the wallet from the repaired rows in one transaction, so losing +outputs disappear and genuinely released materialized inputs become available. +Replay preserves a surviving `spent_in_txid` claim even when its winner has +no stored transaction body. Strict loads commit this repair; Recovery loads +return the repaired projection but roll back all database changes. +After replay, every remaining spent row restores an in-memory guard with its +optional claimant, including unmaterialized placeholders. Funding redelivery +cannot credit it; later conflict removal releases a restored guard only when +its known claimant is removed. Unknown claims remain protected. + What gates the funding UTXO's own later upsert (`execute_upsert_utxo`) is the row's shape, not that link: a never-materialised held row (`is_sweep_placeholder = 1`, `spent = 1` — the placeholder `apply_sweep` writes for an input whose funding this store had not seen) stays spent when the funding arrives, with @@ -440,6 +463,33 @@ spent and is permanently outside the collector's reach. the collector's per-round scan touches tombstones rather than the wallet's full spent history. +### `core_transaction_inputs` + +Raw-input index (V019): one row per input outpoint of every stored transaction +record, written whether or not the store knows the spent output yet. When an +owned output is recorded later, the runtime history repair looks up its +outpoint here to find the stored records that spend it and repairs their +accounting (`input_details`, direction, the output's spent mark). Rows are +written with `INSERT OR IGNORE` on each record write; the V019 migration +backfilled them for records stored before it. + +- PK: `(wallet_id, txid, outpoint)`. +- FK: `(wallet_id, txid) → core_transactions(wallet_id, txid) ON DELETE + CASCADE`. +- Index: `idx_core_transaction_inputs_outpoint(wallet_id, outpoint)`. + +### `core_transaction_record_originals` + +Append-only archive (V019) of the pre-repair `core_transactions.record_blob`. +Before a history repair (runtime or V019) first rewrites a record, the blob it +replaces is copied here with `INSERT OR IGNORE`, so the first original is kept +verbatim and never replaced. It is never loaded; it exists so a wrong repair +can be undone by hand. + +- PK: `(wallet_id, txid)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. There is no FK to + `core_transactions`: the original outlives its transaction row. + ### `core_instant_locks` Instant-lock blobs for transactions that are broadcast but not yet @@ -596,6 +646,9 @@ unfiltered inspection reader (`schema::asset_locks::list_active`). The rehydration feed reads through `schema::asset_locks::load_unconsumed`, which filters at the SQL level (`status NOT IN ('consumed')`), so a spent one-shot lock is never resurrected as actionable. +Load-time Core conflict reconciliation also removes the losing transactions' +non-consumed lifecycle rows in the same wallet transaction. Consumed history +survives, and Recovery rolls back both Core and lifecycle repairs. - PK: `(wallet_id, outpoint)`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. @@ -847,3 +900,4 @@ table-rebuild migration, as V004 does. | V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | | V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | | V018 | `V018__identity_hard_delete.rs` | Retires identity tombstoning. Adds `cascade_children_on_identity_delete` (brooms `identity_keys` / `contacts` / `ignored_senders` / `pending_contact_crypto` by the deleted identity id, covering the rows no live FK reaches) plus its access-path indexes `idx_contacts_owner`, `idx_ignored_senders_owner`, and `idx_pending_contact_crypto_owner`; purges every already-tombstoned identity and its dependents; drops `identities.tombstoned`. | +| V019 | `V019__core_transaction_accounting.rs` | Adds `core_transaction_inputs` (raw-input index, with `idx_core_transaction_inputs_outpoint`) and `core_transaction_record_originals` (append-only archive of pre-repair record blobs). A data repair (`legacy_v019::repair_history`) then runs once over every stored record: it backfills the input index, rewrites `core_transactions.record_blob` with corrected input details and direction (archiving the original first), and marks spent the owned outputs that non-mempool records spend, recording the spender in `spent_in_txid` unless another claim stands. A confirmed record whose blob cannot be decoded (or exceeds the blob size limit) is dropped along with its index rows and `core_sync_state.synced_height` is lowered to just below the wallet's birth height, so the next SPV start rescans it; an undecodable unconfirmed record fails the migration instead. | diff --git a/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs new file mode 100644 index 00000000000..d563fd867f0 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs @@ -0,0 +1,22 @@ +//! Index raw inputs so late output ownership can repair the spending history, +//! and keep each record's pre-repair blob so every repair stays reversible. + +pub fn migration() -> String { + "CREATE TABLE core_transaction_inputs ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + outpoint BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid, outpoint), + FOREIGN KEY (wallet_id, txid) REFERENCES core_transactions(wallet_id, txid) ON DELETE CASCADE + ); + CREATE INDEX idx_core_transaction_inputs_outpoint + ON core_transaction_inputs(wallet_id, outpoint); + CREATE TABLE core_transaction_record_originals ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + record_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE + );" + .to_owned() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 9f843edf7bb..029476658ed 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -32,7 +32,11 @@ pub enum AutoBackupOperation { } /// Errors produced by the wallet-storage SQLite backend. +/// +/// `#[non_exhaustive]`: new failure modes get their own variant, so matches +/// outside this crate need a wildcard arm. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum WalletStorageError { /// File-system I/O error reaching the database or backup files. #[error("io error")] @@ -418,6 +422,38 @@ pub enum WalletStorageError { blob_height: Option, }, + /// 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. + #[error( + "transaction {txid} in wallet {} arrived with a body whose txid differs from the stored one", + hex::encode(wallet_id) + )] + TransactionBodyConflict { + wallet_id: [u8; 32], + txid: dashcore::Txid, + }, + + /// The `wallets.network` label is not one this build knows, so stored + /// scripts cannot be turned back into addresses. + #[error( + "wallet {} has unknown network label {label:?}", + hex::encode(wallet_id) + )] + UnknownWalletNetwork { wallet_id: [u8; 32], label: String }, + + /// A transaction's net amount (owned outputs minus owned inputs) does not + /// fit the `i64` the record stores. + #[error( + "net amount {value} of transaction {txid} in wallet {} does not fit i64", + hex::encode(wallet_id) + )] + NetAmountOverflow { + wallet_id: [u8; 32], + txid: dashcore::Txid, + value: i128, + }, + /// A blob exceeded the decode allocation cap (default 16 MiB). /// Separate from [`Self::BlobDecode`] so operators can distinguish an /// oversize blob from a structural decode failure. @@ -754,6 +790,9 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::TransactionBodyConflict { .. } + | Self::UnknownWalletNetwork { .. } + | Self::NetAmountOverflow { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -799,6 +838,11 @@ impl WalletStorageError { // Typed re-mapping of an FK violation — same class as the raw // `ConstraintViolation` above, so it reports the same kind. 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 + } // 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 @@ -858,6 +902,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::UnknownWalletNetwork { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -939,6 +984,9 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", + Self::TransactionBodyConflict { .. } => "transaction_body_conflict", + Self::UnknownWalletNetwork { .. } => "unknown_wallet_network", + Self::NetAmountOverflow { .. } => "net_amount_overflow", Self::BlobTooLarge { .. } => "blob_too_large", Self::IntegerOverflow { .. } => "integer_overflow", Self::RehydrationPoolMismatch { .. } => "rehydration_pool_mismatch", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index b009d6ddeae..77e76fc1f77 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -10,6 +10,7 @@ use crate::sqlite::error::WalletStorageError; use refinery_core::error::WrapMigrationError; mod legacy_v008; +mod legacy_v019; // Generates a `migrations` module with `runner()`; path is relative to // the crate root. @@ -45,6 +46,7 @@ fn run_with_runner( tx, registration_sql: hook_sql(8), pool_sql: hook_sql(11), + history_sql: hook_sql(19), }; // Grouped reports never claim that rolled-back migrations were applied. let report = runner.set_grouped(true).run(&mut driver)?; @@ -59,6 +61,7 @@ struct MigrationTransaction<'conn> { tx: rusqlite::Transaction<'conn>, registration_sql: String, pool_sql: String, + history_sql: String, } impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { @@ -75,6 +78,8 @@ impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { legacy_v008::backfill_registrations(&self.tx)?; } else if query == self.pool_sql { legacy_v008::convert_pools(&self.tx)?; + } else if query == self.history_sql { + legacy_v019::repair_history(&self.tx)?; } count += 1; } @@ -420,6 +425,16 @@ pub fn embedded_migrations_sql() -> Vec { .collect() } +/// Undo V019 so the next [`run`] replays it over the current rows. +#[cfg(test)] +pub(crate) fn rewind_to_v018(conn: &rusqlite::Connection) { + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs new file mode 100644 index 00000000000..a9db717433b --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -0,0 +1,730 @@ +//! Frozen V019 history repair. It backfills the raw-input index and corrects +//! stored accounting for databases migrating from V018 or earlier. +//! +//! Frozen on purpose: the live per-round repair in `schema::core_history` may +//! evolve, but V019 must keep doing exactly what it did when it shipped. It +//! shares only these live helpers, which must stay behaviour-stable: the blob +//! codec and its size/width gates (`blob`), `id32`, `wallets::parse_network`, +//! `i64_to_u64`, `i64_to_u32` and the `WalletStorageError` variants they return. +//! `TransactionRecord`'s encoding is owned upstream (key-wallet) and cannot be +//! frozen here. Do not edit. + +use std::collections::BTreeMap; + +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, ScriptBuf, Txid}; +use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::{blob, id32, wallets}; +use crate::sqlite::util::safe_cast::{i64_to_u32, i64_to_u64}; + +/// Read a stored record; undecodable bytes are an error the caller classifies. +fn read_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + let key = params![wallet_id.as_slice(), txid.as_byte_array().as_slice()]; + // Gate the stored length in its own statement: SQLite evaluates every + // result column before a row is returned, so selecting the payload + // alongside its length would load an oversize blob before the check. + let len: Option> = tx + .prepare_cached( + "SELECT length(record_blob) FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )? + .query_row(key, |row| row.get(0)) + .optional()?; + let Some(Some(len)) = len else { + return Ok(None); + }; + blob::check_size(len)?; + let payload: Vec = tx + .prepare_cached( + "SELECT record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )? + .query_row(key, |row| row.get(0))?; + let record: TransactionRecord = blob::decode(&payload)?; + if record.txid != *txid { + return Err(WalletStorageError::blob_decode( + "transaction record names another transaction", + )); + } + Ok(Some(record)) +} + +/// Whether `error` reports stored bytes that cannot be decoded, not a database failure. +fn is_unreadable(error: &WalletStorageError) -> bool { + matches!( + error, + WalletStorageError::BincodeDecode { .. } + | WalletStorageError::BlobDecode { .. } + | WalletStorageError::BlobTooLarge { .. } + | WalletStorageError::HashDecode { .. } + | WalletStorageError::IntegerOverflow { .. } + ) +} + +/// Drop an undecodable record that a Core resync re-delivers, and force that resync. +/// +/// Only a block-confirmed record (typed `height` set) is re-delivered by a +/// filter rescan; an unconfirmed one may never be seen again, so it keeps the +/// migration failing rather than losing it. Lowering `synced_height` to just +/// below the wallet's birth height makes the next SPV start rescan the wallet +/// from its birth, which re-records the transaction and re-applies its spends. +/// The spent marks and outputs it already produced stay as they are: +/// conservative until the rescan confirms them. +fn drop_for_resync( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, + error: WalletStorageError, +) -> Result<(), WalletStorageError> { + let height: Option = tx.query_row( + "SELECT height FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| row.get(0), + )?; + if height.is_none() { + return Err(error); + } + let birth_height: i64 = tx.query_row( + "SELECT birth_height FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + // A corrupt birth height fails the migration instead of choosing a rescan height. + let rescan_from = i64_to_u32("wallets.birth_height", birth_height)?.saturating_sub(1); + tx.execute( + "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_sync_state SET synced_height = MIN(COALESCE(synced_height, ?2), ?2) \ + WHERE wallet_id = ?1", + params![wallet_id.as_slice(), rescan_from], + )?; + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %txid, + %error, + rescan_from, + "dropped an undecodable confirmed transaction record; Core history rescans from birth" + ); + Ok(()) +} + +/// Index raw inputs independently of when their ownership becomes known. +fn index_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) VALUES (?1, ?2, ?3)", + )?; + for input in &record.transaction.input { + stmt.execute(params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode_outpoint(&input.previous_output)?, + ])?; + } + Ok(()) +} + +fn network( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let label: String = tx.query_row( + "SELECT network FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |r| r.get(0), + )?; + wallets::parse_network(&label).ok_or_else(|| WalletStorageError::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) +} + +fn owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let encoded = blob::encode_outpoint(outpoint)?; + let key = params![wallet_id.as_slice(), encoded]; + // Gate the script length in its own statement: SQLite evaluates every + // result column before a row is returned. + let Some((value, len)) = tx + .prepare_cached( + "SELECT value, length(script) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .optional()? + else { + return Ok(None); + }; + let value = i64_to_u64("core_utxos.value", value)?; + blob::check_size(len)?; + let script: Vec = tx + .prepare_cached( + "SELECT script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| row.get(0))?; + if contact_only_script(tx, wallet_id, &script)? { + return Ok(None); + } + let address = Address::from_script(&ScriptBuf::from_bytes(script), network)?; + Ok(Some((value, address))) +} + +/// Whether `script` is tracked only by a contact's watch-only (DashPay external) chain. +fn contact_only_script( + conn: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) \ + AND NOT EXISTS(SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", + params![wallet_id.as_slice(), script], + |r| r.get(0), + )?) +} + +fn repair_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + mut record: TransactionRecord, + network: dashcore::Network, +) -> Result<(), WalletStorageError> { + let record_txid = record.txid; + let txid = &record_txid; + let original = blob::encode(&record)?; + let mut inputs = BTreeMap::new(); + for detail in record.input_details.drain(..) { + if !contact_only_script(tx, wallet_id, detail.address.script_pubkey().as_bytes())? { + inputs.insert(detail.index, detail); + } + } + for (index, input) in record.transaction.input.iter().enumerate() { + if let Some((value, address)) = + owned_output(tx, wallet_id, &input.previous_output, network)? + { + inputs.insert( + index as u32, + InputDetail { + index: index as u32, + value, + address, + }, + ); + // Stale mempool rows cannot overrule a later sweep's release. + if !matches!(record.context, TransactionContext::Mempool) { + // Record the spender so the mark stays attributable and + // reversible; an existing claim by another spender stands. + tx.execute( + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() + ], + )?; + } + } + } + let mut outputs = BTreeMap::new(); + for mut detail in record.output_details.drain(..) { + if let Some(address) = &detail.address { + if contact_only_script(tx, wallet_id, address.script_pubkey().as_bytes())? { + detail.role = OutputRole::Sent; + } + } + outputs.insert(detail.index, detail); + } + for (index, output) in record.transaction.output.iter().enumerate() { + let index = index as u32; + if let Some((_, address)) = owned_output( + tx, + wallet_id, + &OutPoint { + txid: *txid, + vout: index, + }, + network, + )? { + let role = outputs.get(&index).map_or(OutputRole::Received, |d| { + if d.role == OutputRole::Change { + OutputRole::Change + } else { + OutputRole::Received + } + }); + outputs.insert( + index, + OutputDetail { + index, + role, + address: Some(address), + value: output.value, + }, + ); + } + } + // Empty metadata is not accounting evidence (e.g. confirmation-only placeholders). + if inputs.is_empty() && outputs.is_empty() { + return Ok(()); + } + let received: i128 = outputs + .values() + .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) + .map(|d| i128::from(d.value)) + .sum(); + let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); + let net = received - spent; + record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: *txid, + value: net, + })?; + let has_ours = outputs + .values() + .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); + let has_external = record + .transaction + .output + .iter() + .enumerate() + .any(|(i, output)| { + !output.script_pubkey.is_op_return() + && !outputs.get(&(i as u32)).is_some_and(|d| { + matches!( + d.role, + OutputRole::Received | OutputRole::Change | OutputRole::Unspendable + ) + }) + }); + record.direction = if record.transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if inputs.is_empty() { + TransactionDirection::Incoming + } else if !has_external && (has_ours || record.transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + }; + record.input_details = inputs.into_values().collect(); + record.output_details = outputs.into_values().collect(); + let repaired = blob::encode(&record)?; + if repaired != original { + // Append-only: the first pre-repair blob is kept verbatim and never + // replaced, so a wrong repair can always be undone. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) \ + SELECT wallet_id, txid, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + repaired, + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ], + )?; + } + Ok(()) +} + +pub(super) fn repair_history(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { + // Keys are collected first so drops never race the open cursor. + let mut keys = Vec::new(); + { + let mut stmt = tx.prepare_cached( + "SELECT length(wallet_id), wallet_id, length(txid), txid \ + FROM core_transactions WHERE record_blob IS NOT NULL", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; + let wallet_id: Vec = row.get(1)?; + let wallet_id = id32("core_transactions.wallet_id", &wallet_id)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid: Vec = row.get(3)?; + keys.push((wallet_id, Txid::from_slice(&txid)?)); + } + } + for (wallet_id, txid) in keys { + match read_record(tx, &wallet_id, &txid) { + Ok(Some(record)) => { + index_record(tx, &wallet_id, &record)?; + repair_record(tx, &wallet_id, record, network(tx, &wallet_id)?)?; + } + Ok(None) => {} + Err(error) if is_unreadable(&error) => drop_for_resync(tx, &wallet_id, &txid, error)?, + Err(error) => return Err(error), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use dashcore::address::Payload; + use dashcore::{BlockHash, PubkeyHash, Transaction as CoreTransaction, TxIn, TxOut}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + use platform_wallet::changeset::CoreChangeSet; + use rusqlite::Connection; + + use super::*; + use crate::sqlite::migrations::{self, rewind_to_v018}; + use crate::sqlite::schema::core_state; + + #[test] + fn should_reject_oversize_owned_output_script_before_reading_it() { + const WALLET: WalletId = [0xC2u8; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&WALLET[..]], + ) + .unwrap(); + + use rusqlite::limits::Limit; + + use crate::sqlite::conn::SQLITE_MAX_BLOB_BYTES; + + let outpoint = OutPoint::new(Txid::from_byte_array([0x42; 32]), 0); + // Over the connection's length cap, so reading the column itself + // fails: only a length-only pre-read reports it as oversize. + let script = vec![0u8; SQLITE_MAX_BLOB_BYTES as usize + 1]; + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + &WALLET[..], + blob::encode_outpoint(&outpoint).unwrap(), + script + ], + ) + .unwrap(); + drop(script); + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) + .unwrap(); + let tx = conn.transaction().unwrap(); + + let err = owned_output(&tx, &WALLET, &outpoint, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "got {err:?}" + ); + } + + fn address(marker: u8) -> Address { + Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([marker; 20])), + ) + } + + fn utxo(outpoint: OutPoint, value: u64, address: Address) -> Utxo { + Utxo { + outpoint, + txout: TxOut { + value, + script_pubkey: address.script_pubkey(), + }, + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } + } + + /// A V018 database holding one oversize confirmed record for a wallet born + /// at `birth_height`, synced to 900. + fn seed_oversize_confirmed_record(birth_height: i64) -> (Connection, WalletId) { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xC2u8; 32]; + let txid = Txid::from_byte_array([0x72; 32]); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + params![&wallet_id[..], birth_height], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 900, 900)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 300, 1, zeroblob(?3))", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES + 1).unwrap() + ], + ) + .unwrap(); + rewind_to_v018(&conn); + (conn, wallet_id) + } + + /// `(record count, synced_height)` for `wallet_id`. + fn records_and_synced_height(conn: &Connection, wallet_id: &WalletId) -> (i64, i64) { + conn.query_row( + "SELECT (SELECT count(*) FROM core_transactions WHERE wallet_id = ?1), \ + (SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1)", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + } + + /// An oversize confirmed record fails its length gate before its payload + /// is read, and is dropped with a rescan from just below the birth height. + #[test] + fn should_drop_oversize_confirmed_record_for_resync() { + let (mut conn, wallet_id) = seed_oversize_confirmed_record(50); + + migrations::run(&mut conn).unwrap(); + + let (records, synced) = records_and_synced_height(&conn, &wallet_id); + assert_eq!(records, 0, "the oversize record must be dropped"); + assert_eq!( + synced, 49, + "the rescan must restart just below the birth height" + ); + } + + #[test] + fn should_rescan_from_genesis_when_the_wallet_is_born_at_zero() { + let (mut conn, wallet_id) = seed_oversize_confirmed_record(0); + + migrations::run(&mut conn).unwrap(); + + assert_eq!(records_and_synced_height(&conn, &wallet_id), (0, 0)); + } + + /// A stored birth height outside `u32` fails the migration with a typed + /// error, instead of overflowing, and leaves the record and sync state alone. + #[test] + fn should_reject_out_of_range_birth_height_before_scheduling_a_rescan() { + for birth_height in [i64::MIN, -1, i64::from(u32::MAX) + 1, i64::MAX] { + let (mut conn, wallet_id) = seed_oversize_confirmed_record(birth_height); + + let error = migrations::run(&mut conn).unwrap_err(); + + assert!( + format!("{error:?}").contains("wallets.birth_height"), + "birth height {birth_height}: {error:?}" + ); + assert_eq!( + records_and_synced_height(&conn, &wallet_id), + (1, 900), + "birth height {birth_height}: the failed upgrade must roll back" + ); + } + } + + /// Pins V019's observable result on a V018-shaped database: the repaired + /// record, the preserved original, the input index and the spent marks. + #[test] + fn should_pin_v019_repair_of_a_v018_database() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xC1u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let (own, change, contact, external) = (address(1), address(2), address(3), address(4)); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + let funding = OutPoint::new(Txid::from_byte_array([0x71; 32]), 0); + let body = CoreTransaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: funding, + ..Default::default() + }], + output: vec![ + TxOut { + value: 30_000, + script_pubkey: change.script_pubkey(), + }, + TxOut { + value: 50_000, + script_pubkey: contact.script_pubkey(), + }, + TxOut { + value: 15_000, + script_pubkey: external.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let txid = body.txid(); + // What an old build stored: the input was not known to be ours and + // the contact's output was credited as received. + let original = TransactionRecord::new( + body, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new(101, BlockHash::all_zeros(), 7)), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![ + OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(change.clone()), + value: 30_000, + }, + OutputDetail { + index: 1, + role: OutputRole::Received, + address: Some(contact), + value: 50_000, + }, + ], + 80_000, + ); + { + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![ + utxo(funding, 100_000, own.clone()), + utxo(OutPoint::new(txid, 0), 30_000, change.clone()), + ], + ..Default::default() + }, + ) + .unwrap(); + tx.execute( + "INSERT OR REPLACE INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 101, 1, ?3)", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + blob::encode(&original).unwrap() + ], + ) + .unwrap(); + rewind_to_v018(&tx); + tx.commit().unwrap(); + } + + migrations::run(&mut conn).unwrap(); + + let mut expected = original.clone(); + expected.input_details = vec![InputDetail { + index: 0, + value: 100_000, + address: own, + }]; + expected.output_details = vec![ + OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(change), + value: 30_000, + }, + OutputDetail { + index: 1, + role: OutputRole::Sent, + address: Some(address(3)), + value: 50_000, + }, + ]; + expected.net_amount = -70_000; + expected.direction = TransactionDirection::Outgoing; + let read_blob = |sql: &str| -> Vec { + conn.query_row( + sql, + params![&wallet_id[..], txid.as_byte_array().as_slice()], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + read_blob( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2" + ), + blob::encode(&expected).unwrap() + ); + assert_eq!( + read_blob( + "SELECT record_blob FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2" + ), + blob::encode(&original).unwrap() + ); + let (spent, spender): (bool, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(&funding).unwrap()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!(spent); + assert_eq!(spender.as_deref(), Some(txid.as_byte_array().as_slice())); + let indexed: i64 = conn + .query_row( + "SELECT count(*) FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2 AND outpoint = ?3", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + blob::encode_outpoint(&funding).unwrap() + ], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(indexed, 1); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index fd865405121..2643e5feb8a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; use dpp::prelude::Identifier; +use platform_wallet::changeset::changeset::SweepBatch; use platform_wallet::changeset::{ ClientStartState, IdentityChangeSet, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, @@ -21,6 +22,7 @@ use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; use crate::sqlite::load_ctx::{LoadCtx, LoadDegradation, LoadSite}; use crate::sqlite::rehydrate::{ apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool, + restore_recorded_transactions, }; use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; use crate::sqlite::schema; @@ -1470,9 +1472,10 @@ impl PlatformWalletPersistence for SqlitePersister { /// /// # Concurrency /// - /// Holds the connection mutex for the whole read, so concurrent + /// Holds the connection mutex for the whole load, so concurrent /// `store` / `flush` / `delete_wallet` block until it returns. Intended /// for one-shot startup use, not the hot write path. + /// Conflict repairs commit per wallet under Strict; Recovery rolls them back. /// /// # Examples /// @@ -1566,7 +1569,7 @@ impl PlatformWalletPersistence for SqlitePersister { if unreadable.contains(&wallet_id) { continue; } - match load_one_wallet(&conn, wallet_id, &ctx) { + match load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy) { Ok(wallet_state) => { state.wallets.insert(wallet_id, wallet_state); } @@ -1683,7 +1686,49 @@ fn load_one_wallet( conn: &Connection, wallet_id: WalletId, ctx: &LoadCtx, + policy: LoadPolicy, ) -> Result { + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + let (mut state, sweeps) = load_wallet_snapshot(&tx, wallet_id, ctx)?; + if !sweeps.is_empty() { + schema::core_state::apply_replay_sweeps(&tx, &wallet_id, sweeps) + .map_err(PersistenceError::from)?; + // Rebuild from the repaired projection, including released materialized inputs. + let (repaired, remaining) = load_wallet_snapshot(&tx, wallet_id, ctx)?; + if !remaining.is_empty() { + return Err(PersistenceError::from( + WalletStorageError::WalletRehydrationFailed { + wallet_id, + cause: "conflicting transaction history remained after replay reconciliation" + .to_string(), + }, + )); + } + state = repaired; + } + if policy == LoadPolicy::Recovery { + tx.rollback() + } else { + tx.commit() + } + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + Ok(state) +} + +fn load_wallet_snapshot( + conn: &Connection, + wallet_id: WalletId, + ctx: &LoadCtx, +) -> Result< + ( + platform_wallet::changeset::ClientWalletStartState, + Vec, + ), + PersistenceError, +> { let (network_str, birth_height) = schema::wallets::fetch(conn, &wallet_id) .map_err(PersistenceError::from)? .ok_or_else(|| { @@ -1849,17 +1894,32 @@ fn load_one_wallet( )) })?; } - Ok(platform_wallet::changeset::ClientWalletStartState { - wallet, - wallet_info, - identity_manager, - unused_asset_locks, - // This backend does not stage unconfirmed outgoing sends for replay - // yet; the FFI persister is the only producer today. Empty leaves the - // replay inert here, which is the behaviour this path had before the - // field existed. - unconfirmed_outgoing_txs: Vec::new(), - }) + let mut wallet = wallet; + let sweeps = restore_recorded_transactions( + &mut wallet_info, + &mut wallet, + core_state.records, + &core_state.instant_locks_for_non_final_records, + ) + .map_err(PersistenceError::from)?; + // Restore durable claims after replay/finality, including claims with no spend body. + let spent = + schema::core_state::load_spent_claims(conn, &wallet_id).map_err(PersistenceError::from)?; + wallet_info.restore_spent_outpoints(&spent); + Ok(( + platform_wallet::changeset::ClientWalletStartState { + wallet, + wallet_info, + identity_manager, + unused_asset_locks, + // This backend does not stage unconfirmed outgoing sends for replay + // yet; the FFI persister is the only producer today. Empty leaves the + // replay inert here, which is the behaviour this path had before the + // field existed. + unconfirmed_outgoing_txs: Vec::new(), + }, + sweeps, + )) } /// Count one wallet's whole loss and attribute it, or return so the caller @@ -2402,18 +2462,22 @@ mod tests { // Not rehydrated by `load()`, but read on demand by a production // entry point, so the state is reachable rather than abandoned. const READ_BY_A_DEDICATED_API: &[&str] = &[ - "dpns_name_states", // get_dpns_name_state - "meta_contact", // the kv object store - "meta_data_versions", // schema::versions - "meta_global", // the kv object store - "meta_identity", // the kv object store - "meta_platform_address", // the kv object store - "meta_store_generation", // schema::versions - "meta_token", // the kv object store - "meta_wallet", // the kv object store - "tracked_masternodes", // load_tracked_masternodes + "core_transaction_inputs", // core_history::apply repairs indexed consumers + "dpns_name_states", // get_dpns_name_state + "meta_contact", // the kv object store + "meta_data_versions", // schema::versions + "meta_global", // the kv object store + "meta_identity", // the kv object store + "meta_platform_address", // the kv object store + "meta_store_generation", // schema::versions + "meta_token", // the kv object store + "meta_wallet", // the kv object store + "tracked_masternodes", // load_tracked_masternodes ]; const INFRASTRUCTURE: &[&str] = &["refinery_schema_history"]; + // Append-only archive of pre-repair history blobs. Never loaded: it + // exists so a wrong history repair can be undone by hand. + const RETAINED_FOR_RECOVERY: &[&str] = &["core_transaction_record_originals"]; // `load()` rehydrates these only with the `shielded` feature on, so // the classification follows the build rather than claiming one. #[cfg(feature = "shielded")] @@ -2455,6 +2519,7 @@ mod tests { && !READ_BY_A_DEDICATED_API.contains(&table.as_str()) && !LOAD_UNIMPLEMENTED_TABLES.contains(&table.as_str()) && !INFRASTRUCTURE.contains(&table.as_str()) + && !RETAINED_FOR_RECOVERY.contains(&table.as_str()) && !FEATURE_GATED.contains(&table.as_str()) && !NOT_REHYDRATED_WITHOUT_FEATURE.contains(&table.as_str()) }) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 270e0a436d0..b289bbf2319 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,13 +4,24 @@ //! the manager consumes the carried snapshot directly, so no wrong-seed check //! runs here; that gate lives in the resolver-backed signing entrypoints. +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::transaction::TransactionPayload; +use dashcore::{OutPoint, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; +use platform_wallet::changeset::changeset::SweepBatch; use platform_wallet::changeset::provider_key_account::{ rebuild_provider_key_account, ProviderAccountRebuildError, }; @@ -21,6 +32,7 @@ use crate::sqlite::provider_accounts::{insert_platform_node_pool_entry, Platform use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; use crate::sqlite::schema::accounts::{self, AccountManifest}; use crate::sqlite::schema::core_pool::{self, OwningAccount}; +use crate::sqlite::util::safe_cast; use crate::WalletStorageError; /// Build a [`Wallet`] that will be provided to the platform-wallet during rehydration. @@ -258,8 +270,9 @@ pub(crate) fn restore_provider_platform_node_pool( /// Coinbase-maturity nuance re-warms on sync. `is_instantlocked` is NOT /// among them: it is rebuilt from `core_instant_locks` above, for every /// UTXO a replayed lock covers. -/// - **Transaction-record history**: rebuilt by the next scan; not a -/// balance input. +/// - **Transaction records**: the SQLite loader replays recorded history +/// through the wallet checker after this projection, in dependency order +/// (in-set parents first, otherwise chain order). /// /// # Errors /// @@ -300,12 +313,6 @@ pub fn apply_persisted_core_state( wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); } - // INTENTIONAL(tx-record-rehydration-gap): `core` also carries transaction - // records, but they cannot be replayed here — injecting one needs the raw - // `dashcore::Transaction`, and this crate persists only the abstracted - // `TransactionRecord` blob. History re-warms on the next scan and is not a - // balance input. - // Restore the UTXO set, routing each unspent outpoint to its true owning // funds account via `utxo_accounts` (matched on the same account identity // the writer keyed the pool row on). Two miss cases share the first-account @@ -420,6 +427,467 @@ pub fn apply_persisted_core_state( Ok(()) } +/// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. +/// +/// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a +/// redelivered funding transaction would re-credit an output they reserve. +/// `instant_locks` are the persisted InstantSend locks: a lock that arrived +/// after its transaction was stored never rewrote the stored record, so the +/// replay upgrades that record's mempool context itself. +pub(crate) fn restore_recorded_transactions( + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + records: Vec, + instant_locks: &BTreeMap, +) -> Result, WalletStorageError> { + validate_replay_amounts(wallet_info, &records)?; + // Where the load projection parked each unspent outpoint; its keys are + // the outputs persistence still considers unspent. + let placed: HashMap = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + account.utxos.keys().map(move |outpoint| (*outpoint, owner)) + }) + .collect(); + if records.is_empty() { + return Ok(Vec::new()); + } + // TODO(bound-load-history-replay): every stored record is replayed on each + // load; bounding it to records above the last chain lock needs care so + // finality and spend guards for older records are not lost. + // TODO(expire-unconfirmed-spend-reservations): mempool records are replayed + // on every load with no expiry, so a forged or never-mined spend of a wallet + // outpoint keeps its reservation across load and rescan; only a conflicting + // IS-locked or confirmed spend releases it. Sibling of + // TODO(release-repair-spends-after-reorg) in `core_history`. + let mut replay = replay_order(records); + for record in &mut replay { + record.context = match &record.context { + TransactionContext::InBlock(block) + if wallet_info + .metadata + .last_applied_chain_lock + .as_ref() + .is_some_and(|lock| block.height() <= lock.block_height) => + { + TransactionContext::InChainLockedBlock(*block) + } + TransactionContext::Mempool => instant_locks + .get(&record.txid) + .filter(|lock| lock_matches_record(lock, record)) + .map(|lock| TransactionContext::InstantSend(lock.clone())) + .unwrap_or(TransactionContext::Mempool), + context => context.clone(), + }; + } + + // Kept only to undo a replay the checker suspended part-way through. + let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); + let mut sweeps = plan_replay_sweeps(wallet_info, &replay); + let removed: HashSet<_> = sweeps + .iter() + .flat_map(|sweep| sweep.txids.iter().copied()) + .collect(); + replay.retain(|record| !removed.contains(&record.txid)); + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| !removed.contains(&outpoint.txid)); + } + stage_recorded_spent_inputs(wallet_info, &replay, &placed); + let completed = poll_ready(async { + for record in &replay { + let result = wallet_info + .check_core_transaction( + &record.transaction, + record.context.clone(), + wallet, + true, + false, + ) + .await; + if !result.swept_transactions.is_empty() { + sweeps.push(replay_sweep( + record, + result.swept_transactions, + result.released_outpoints, + )); + } + } + }) + .is_some(); + if !completed { + // Degrade to the pre-replay projection: persisted spends stay + // excluded, only redelivery guards are missing until the next sync. + tracing::error!( + wallet_id = %hex::encode(wallet_info.wallet_id), + "transaction checker suspended during load replay; restored spend guards skipped" + ); + *wallet_info = info_before; + *wallet = wallet_before; + return Ok(Vec::new()); + } + // A settled spend's first sweep cannot reach competitors replayed later. + // The upstream sweep preserves ChainLock/InstantSend precedence. + for record in &replay { + let result = wallet_info.sweep_conflicts(&record.transaction, &record.context); + if !result.txids.is_empty() { + sweeps.push(replay_sweep( + record, + result.txids, + result.released_outpoints, + )); + } + } + + let spent: HashSet<_> = wallet_info + .observed_spent_outpoints() + .keys() + .copied() + .collect(); + // Replay credits an output to the account whose pool derives it. When + // that differs from the load-time fallback, the fallback copy is a + // duplicate: drop it so each outpoint lives in exactly one account. + let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + let placed = &placed; + account.utxos.keys().filter_map(move |outpoint| { + placed + .get(outpoint) + .filter(|parked| **parked != owner) + .map(|parked| (*outpoint, *parked)) + }) + }) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + account.utxos.retain(|outpoint, _| { + placed.contains_key(outpoint) + && !spent.contains(outpoint) + && !misplaced.contains(&(*outpoint, owner)) + }); + } + // Finalize replayed records before a sync checkpoint can prune their spend guards. + if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { + wallet_info.apply_chain_lock(chain_lock); + } + wallet_info.update_balance(); + Ok(sweeps) +} + +/// The upstream checker casts each unsigned operand before signed subtraction. +/// A fitting net does not make an overflowing operand safe. Include every source +/// of replay inputs: durable coins, parent outputs, and staged input details. +fn validate_replay_amounts( + wallet_info: &ManagedWalletInfo, + records: &[TransactionRecord], +) -> Result<(), WalletStorageError> { + fn total( + field: &'static str, + values: impl IntoIterator, + ) -> Result<(), WalletStorageError> { + let mut sum = 0_u64; + for value in values { + safe_cast::u64_to_i64(field, value)?; + // Both operands are at most i64::MAX, so this addition fits u64. + sum += value; + safe_cast::u64_to_i64(field, sum)?; + } + Ok(()) + } + + let mut amounts: HashMap = HashMap::new(); + let mut remember = |outpoint, value| { + amounts + .entry(outpoint) + .and_modify(|known| *known = (*known).max(value)) + .or_insert(value); + }; + for account in wallet_info.accounts.all_funding_accounts() { + for (outpoint, coin) in &account.utxos { + safe_cast::u64_to_i64("replay.utxo", coin.txout.value)?; + remember(*outpoint, coin.txout.value); + } + } + for record in records { + total( + "replay.input_details", + record.input_details.iter().map(|d| d.value), + )?; + total( + "replay.output_details", + record.output_details.iter().map(|d| d.value), + )?; + let credit_outputs = match &record.transaction.special_transaction_payload { + Some(TransactionPayload::AssetLockPayloadType(payload)) => { + payload.credit_outputs.as_slice() + } + _ => &[], + }; + total( + "replay.outputs", + record + .transaction + .output + .iter() + .chain(credit_outputs) + .map(|o| o.value), + )?; + let txid = record.transaction.txid(); + for (index, output) in record.transaction.output.iter().enumerate() { + remember(OutPoint::new(txid, index as u32), output.value); + } + for detail in &record.input_details { + if let Some(input) = record.transaction.input.get(detail.index as usize) { + remember(input.previous_output, detail.value); + } + } + } + for record in records { + total( + "replay.inputs", + record + .transaction + .input + .iter() + .filter_map(|input| amounts.get(&input.previous_output).copied()), + )?; + } + Ok(()) +} + +fn replay_sweep( + record: &TransactionRecord, + txids: Vec, + released_outpoints: Vec, +) -> SweepBatch { + SweepBatch { + txids, + superseded_by: record.txid, + winner_mined_height: record.block_info().map(|block| block.height()), + released_outpoints, + } +} + +/// Resolve the full history before a defeated lock can sweep another spender. +fn plan_replay_sweeps( + wallet_info: &ManagedWalletInfo, + records: &[TransactionRecord], +) -> Vec { + let mut conflicts = wallet_info.clone(); + // A scratch account lets the upstream sweep see descendants across account boundaries. + let Some(account) = conflicts + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + else { + return Vec::new(); + }; + account + .transactions_mut() + .extend(records.iter().map(|record| (record.txid, record.clone()))); + let mut winners: Vec<_> = records + .iter() + .filter(|record| !matches!(record.context, TransactionContext::Mempool)) + .collect(); + winners.sort_by_key(|record| { + let priority = match record.context { + TransactionContext::InChainLockedBlock(_) => 0, + TransactionContext::InstantSend(_) => 1, + _ => 2, + }; + (priority, record.txid) + }); + let mut removed = HashSet::new(); + let mut sweeps = Vec::new(); + for record in winners { + if removed.contains(&record.txid) { + continue; + } + let result = conflicts.sweep_conflicts(&record.transaction, &record.context); + if !result.txids.is_empty() { + removed.extend(result.txids.iter().copied()); + sweeps.push(replay_sweep( + record, + result.txids, + result.released_outpoints, + )); + } + } + // An input released by an earlier sweep can be claimed by a later surviving winner. + let claimed: HashSet<_> = records + .iter() + .filter(|record| !removed.contains(&record.txid)) + .flat_map(|record| { + record + .transaction + .input + .iter() + .map(|input| input.previous_output) + }) + .collect(); + for sweep in &mut sweeps { + sweep + .released_outpoints + .retain(|outpoint| !removed.contains(&outpoint.txid) && !claimed.contains(outpoint)); + } + sweeps +} + +/// Park every owned input a record spends whose funding no replayed record credits. +/// +/// Persistence excludes spent outputs from the load projection, so without +/// this a spender of a height-only funding row replays with no owned input and +/// rebuilds no spent mark; a redelivered funding transaction would then +/// re-credit the coin once finality prunes the observed spend. The stored +/// `input_details` are the evidence: wallet-owned by construction and repaired +/// from persisted outputs. Staged coins are never in `placed`, so the +/// retention pass drops whatever replay leaves behind. +fn stage_recorded_spent_inputs( + wallet_info: &mut ManagedWalletInfo, + records: &[TransactionRecord], + placed: &HashMap, +) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let replayed: HashSet = records.iter().map(|record| record.txid).collect(); + let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); + for record in records { + for detail in &record.input_details { + let Some(input) = record.transaction.input.get(detail.index as usize) else { + continue; + }; + let outpoint = input.previous_output; + if placed.contains_key(&outpoint) || replayed.contains(&outpoint.txid) { + continue; + } + let Some(account) = accounts + .iter_mut() + .find(|account| account.contains_address(&detail.address)) + else { + continue; + }; + account.utxos.entry(outpoint).or_insert_with(|| { + let txout = dashcore::TxOut { + value: detail.value, + script_pubkey: detail.address.script_pubkey(), + }; + key_wallet::Utxo::new(outpoint, txout, detail.address.clone(), 0, false) + }); + } + } +} + +/// Whether `lock` really locks `record`, so upgrading its context is safe. +/// +/// The lock map is keyed by the stored `txid` column and a record's `txid` is +/// stored beside its transaction, so neither is proof on its own. A mismatch +/// keeps the record's stored context: the lock's conflict sweep must not drop +/// history on the strength of a lock that belongs to another transaction. +fn lock_matches_record(lock: &InstantLock, record: &TransactionRecord) -> bool { + let transaction_txid = record.transaction.txid(); + let matches = lock.txid == record.txid && transaction_txid == record.txid; + if !matches { + tracing::warn!( + record_txid = %record.txid, + transaction_txid = %transaction_txid, + lock_txid = %lock.txid, + "persisted InstantSend lock does not match its transaction record; replaying without it" + ); + } + matches +} + +/// Poll `future` once, returning its output only if it completed without suspending. +/// +/// The wallet checker is `async` only by trait shape: it never awaits, so it +/// completes on the first poll and load needs no async runtime. A test pins +/// that; an upstream change that adds a real await fails it. +fn poll_ready(future: F) -> Option { + let mut future = std::pin::pin!(future); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + match future.as_mut().poll(&mut cx) { + std::task::Poll::Ready(output) => Some(output), + std::task::Poll::Pending => None, + } +} + +/// Order records as the chain would deliver them: every in-set parent ahead of +/// its children, otherwise confirmed by block position, then unconfirmed. +/// +/// Dependencies span both partitions: a parent's stored record can still say +/// mempool after it confirmed (a height-only confirmation never rewrites an +/// existing record), while its child's record is already confirmed. +fn replay_order(records: Vec) -> Vec { + let mut records = records; + records.sort_by_key(|record| { + let block = record.block_info(); + ( + block.is_none(), + block.map(|block| (block.height(), block.position())), + record.txid, + ) + }); + let index: HashMap = records + .iter() + .enumerate() + .map(|(position, record)| (record.txid, position)) + .collect(); + let mut children: Vec> = vec![Vec::new(); records.len()]; + let mut waiting_on: Vec = vec![0; records.len()]; + for (child, record) in records.iter().enumerate() { + let parents: BTreeSet = record + .transaction + .input + .iter() + .filter_map(|input| index.get(&input.previous_output.txid).copied()) + .filter(|parent| *parent != child) + .collect(); + waiting_on[child] = parents.len(); + for parent in parents { + children[parent].push(child); + } + } + // Sorted positions, so the smallest ready one is always next in chain order. + let mut ready: BTreeSet = (0..records.len()) + .filter(|position| waiting_on[*position] == 0) + .collect(); + let mut emitted = vec![false; records.len()]; + let mut order = Vec::with_capacity(records.len()); + while let Some(position) = ready.pop_first() { + emitted[position] = true; + order.push(position); + for &child in &children[position] { + waiting_on[child] -= 1; + if waiting_on[child] == 0 { + ready.insert(child); + } + } + } + // Unreachable for real transactions (txids cannot form a cycle); keep the + // rest in chain order rather than drop a reservation. + order.extend((0..records.len()).filter(|position| !emitted[*position])); + let mut slots: Vec> = records.into_iter().map(Some).collect(); + order + .into_iter() + .filter_map(|position| slots[position].take()) + .collect() +} + +/// Account identity of a funds account, stable across replay mutations. +fn funds_account_type(account: &ManagedCoreFundsAccount) -> AccountType { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + account.managed_account_type().to_account_type() +} + /// Resolve an owning account to its position among `account_keys`, or fall /// back to the first funds account. A `None` owner (no attribution available) /// falls back silently; an owner not present in `account_keys` (store drift) @@ -455,9 +923,7 @@ fn route_to_funds_account( /// to pick one account among funding accounts that share a numeric index /// (Standard BIP44/BIP32 and CoinJoin can all sit at index 0; DashPay accounts /// all carry index 0 and differ only by the identity pair). -fn owning_account_of( - account: &key_wallet::managed_account::ManagedCoreFundsAccount, -) -> OwningAccount { +fn owning_account_of(account: &ManagedCoreFundsAccount) -> OwningAccount { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let at = account.managed_account_type().to_account_type(); let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(&at); @@ -533,7 +999,7 @@ const MAX_NORMAL_CHILD_INDEX: u32 = (1u32 << 31) - 1; /// /// Never touches key material — the xpub is the keyless account public key. fn extend_pools_for_restored_addresses( - account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + account: &mut ManagedCoreFundsAccount, manifest: &[AccountRegistrationEntry], restored_addresses: &[key_wallet::Address], wallet_id: [u8; 32], @@ -3163,4 +3629,912 @@ mod tests { "the restored UTXO must carry instant-locked status, not wait for the next sync" ); } + + /// A fresh random wallet and its first BIP44 receive address. + fn wallet_with_receive_address() -> (Wallet, ManagedWalletInfo, dashcore::Address) { + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + (wallet, info, address) + } + + /// Load replays history without an async runtime by polling the checker + /// once. If upstream ever makes it suspend, this fails in CI instead of + /// load silently skipping the restored spend guards in production. + #[test] + fn should_complete_transaction_checker_on_first_poll() { + use dashcore::hashes::Hash; + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([9; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let block = + TransactionContext::InBlock(BlockInfo::new(1, dashcore::BlockHash::all_zeros(), 1)); + for (tx, context) in [(&funding, block), (&spend, TransactionContext::Mempool)] { + let result = + poll_ready(info.check_core_transaction(tx, context, &mut wallet, true, false)); + assert!( + result.is_some_and(|r| r.is_relevant), + "the checker must complete on its first poll" + ); + } + } + + /// Same-block funding and spend, with and without in-block positions: an + /// output the load projection still parks as unspent must end up excluded, + /// and recorded as observed spent, whichever of the two is stored first. + #[tokio::test] + async fn should_exclude_spent_output_for_either_same_height_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + for (spend_first, positioned) in + [(false, false), (true, false), (false, true), (true, true)] + { + let case = format!("spend_first={spend_first} positioned={positioned}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([15; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let (spent, available) = ( + OutPoint::new(funding.txid(), 0), + OutPoint::new(funding.txid(), 1), + ); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let context = |position: u32| { + let block = BlockInfo::new(100, BlockHash::from_byte_array([7; 32]), 100); + TransactionContext::InBlock(if positioned { + block.with_position(position) + } else { + block + }) + }; + let mut records = info + .check_core_transaction(&funding, context(1), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, context(2), &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|r| r + .block_info() + .is_some_and(|b| b.position().is_some() == positioned))); + if spend_first { + records.reverse(); + } + + // A stale projection that still parks the spent output as unspent. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for outpoint in [spent, available] { + account.utxos.insert( + outpoint, + Utxo { + outpoint, + txout: funding.output[outpoint.vout as usize].clone(), + address: address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()) + .unwrap(); + + let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&spent), "{case}"); + assert!(coins.contains_key(&available), "{case}"); + assert!( + restored.observed_spent_outpoints().contains_key(&spent), + "{case}" + ); + assert_eq!(restored.balance.total(), 20_000, "{case}"); + } + } + + /// A lock that arrived after its transaction was stored lives only in + /// `core_instant_locks`; the stored record still says mempool. Replay must + /// restore the InstantSend context and run its conflict sweep, since the + /// already-marked lock deduplicates any later lock event. + #[tokio::test] + async fn should_replay_mempool_record_with_persisted_lock_as_instant_send() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([21; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = |value| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + // Both double spends as stored: unconfirmed, recorded independently. + let (mut winner, mut loser) = (spend(99_000), spend(98_000)); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + assert_eq!(records.len(), 3); + // Unconfirmed siblings replay by txid: lock the later one so the + // loser is already recorded when the winner's sweep runs. + if winner.txid() < loser.txid() { + std::mem::swap(&mut winner, &mut loser); + } + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + + let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); + + // Alone, the locked spend comes back InstantSend. + let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); + let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; + restore_recorded_transactions(&mut alone, &mut wallet, pair, &locks).unwrap(); + assert!( + alone.accounts.standard_bip44_accounts[&0] + .transactions() + .get(&winner.txid()) + .is_some_and(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "the locked record must come back InstantSend, not mempool" + ); + + // Replayed after a conflicting spend, its lock sweeps that spend. + let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); + restore_recorded_transactions(&mut contested, &mut wallet, records.clone(), &locks) + .unwrap(); + assert!( + !contested.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid()), + "the lock's conflict sweep must drop the competing spend" + ); + } + + /// Settled spends must sweep persisted competing change after all siblings replay. + #[tokio::test] + async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + for (settlement, locked_competitor) in [ + ("instant_send", false), + ("block", false), + ("chainlock", false), + ("block", true), + ("chainlock", true), + ("persisted_chainlock", true), + ("persisted_chainlock_below", true), + ] { + for winner_later_txid in [false, true] { + let case = format!("{settlement}, locked_competitor={locked_competitor}, winner_later_txid={winner_later_txid}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = |change| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 99_000 - change, + script_pubkey: dashcore::ScriptBuf::new(), + }, + TxOut { + value: change, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + let (mut winner, mut loser) = (spend(40_000), spend(30_000)); + if (winner.txid() > loser.txid()) != winner_later_txid { + std::mem::swap(&mut winner, &mut loser); + } + // Both siblings as stored: unconfirmed, each credited its change. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + let change = OutPoint::new(tx.txid(), 1); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + change, + Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + ); + } + assert_eq!(records.len(), 3, "{case}"); + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let mut locks = BTreeMap::new(); + if settlement == "instant_send" { + locks.insert(winner.txid(), lock); + } else { + let block = BlockInfo::new(101, BlockHash::from_byte_array([10; 32]), 101); + let record = records + .iter_mut() + .find(|r| r.txid == winner.txid()) + .unwrap(); + record.context = if settlement == "chainlock" { + TransactionContext::InChainLockedBlock(block) + } else { + TransactionContext::InBlock(block) + }; + } + if settlement.starts_with("persisted_chainlock") { + restored.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: if settlement == "persisted_chainlock" { + 101 + } else { + 100 + }, + block_hash: BlockHash::from_byte_array([10; 32]), + signature: [0; 96].into(), + }); + } + if locked_competitor { + locks.insert( + loser.txid(), + InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: loser.txid(), + ..Default::default() + }, + ); + } + + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks).unwrap(); + + let account = &restored.accounts.standard_bip44_accounts[&0]; + let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") + && locked_competitor; + assert_eq!( + account.transactions().contains_key(&loser.txid()), + keep_competitor, + "{case}: only a chainlock can overrule an InstantSend lock" + ); + assert_eq!( + account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), + keep_competitor, + "{case}: the swept spend's change must not stay selectable" + ); + assert!( + account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), + "{case}: the winner's change stays" + ); + let expected_balance = winner.output[1].value + + if keep_competitor { + loser.output[1].value + } else { + 0 + }; + assert_eq!(restored.balance.total(), expected_balance, "{case}"); + } + } + } + + #[tokio::test] + async fn should_remove_cross_account_descendant_of_conflicting_spend_after_replay() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + let mut wallet = Wallet::new_random( + Network::Testnet, + WalletAccountCreationOptions::BIP44AccountsOnly([0, 1].into_iter().collect()), + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let addresses: Vec<_> = [0, 1] + .into_iter() + .map(|index| { + let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; + info.accounts + .standard_bip44_accounts + .get_mut(&index) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap() + }) + .collect(); + let tx = |previous_output: OutPoint, output: TxOut| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![output], + special_transaction_payload: None, + }; + let funding = tx( + OutPoint::new(Txid::from_byte_array([41; 32]), 0), + TxOut { + value: 100_000, + script_pubkey: addresses[0].script_pubkey(), + }, + ); + let root = tx( + OutPoint::new(funding.txid(), 0), + TxOut { + value: 99_000, + script_pubkey: addresses[0].script_pubkey(), + }, + ); + let child = tx( + OutPoint::new(root.txid(), 0), + TxOut { + value: 98_000, + script_pubkey: addresses[1].script_pubkey(), + }, + ); + let winner = tx( + OutPoint::new(funding.txid(), 0), + TxOut { + value: 97_000, + script_pubkey: dashcore::ScriptBuf::new(), + }, + ); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([42; 32]), + height, + )) + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + let funding_info = info.clone(); + records.extend( + info.check_core_transaction( + &root, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + records.extend( + info.check_core_transaction( + &child, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + assert!(info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&root.txid())); + assert!(!info.accounts.standard_bip44_accounts[&1] + .transactions() + .contains_key(&root.txid())); + assert!(info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&child.txid())); + assert!(info.accounts.standard_bip44_accounts[&1] + .transactions() + .contains_key(&child.txid())); + let mut winner_info = funding_info; + records.extend( + winner_info + .check_core_transaction(&winner, block(101), &mut wallet, true, true) + .await + .new_records, + ); + platform_wallet::test_support::fold_wallet_records(&mut records); + let child_outpoint = OutPoint::new(child.txid(), 0); + use crate::{SqlitePersister, SqlitePersisterConfig}; + use platform_wallet::changeset::{ + PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, + }; + let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let path = dir.path().join("cross-account-replay.sqlite"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let funded = Utxo::new( + OutPoint::new(funding.txid(), 0), + funding.output[0].clone(), + addresses[0].clone(), + 100, + false, + ); + let root_coin = Utxo::new( + OutPoint::new(root.txid(), 0), + root.output[0].clone(), + addresses[0].clone(), + 0, + false, + ); + let child_coin = Utxo::new( + child_outpoint, + child.output[0].clone(), + addresses[1].clone(), + 0, + false, + ); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: manifest_for(&wallet), + core: Some(CoreChangeSet { + records, + new_utxos: vec![funded.clone(), root_coin.clone(), child_coin], + spent_utxos: vec![funded, root_coin], + synced_height: Some(101), + last_processed_height: Some(101), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let mut loaded = persister.load().unwrap(); + let loaded = loaded + .wallets + .remove(&wallet.wallet_id) + .unwrap() + .wallet_info; + let loaded_coins = &loaded.accounts.standard_bip44_accounts[&1].utxos; + use key_wallet::wallet::managed_wallet_info::coin_selection::{ + CoinSelector, SelectionStrategy, + }; + use key_wallet::wallet::managed_wallet_info::fee::FeeRate; + let selection = CoinSelector::new(SelectionStrategy::LargestFirst).select_coins( + loaded_coins.values(), + 10_000, + FeeRate::default(), + 101, + ); + assert!( + !loaded_coins.contains_key(&child_outpoint), + "SQLite store/load must discard a cross-account descendant of a conflicting spend" + ); + assert!( + selection.is_err(), + "a conflicting descendant cannot fund a spend" + ); + assert_eq!(loaded.balance.total(), 0); + for txid in [root.txid(), child.txid()] { + assert!(persister + .get_core_tx_record(wallet.wallet_id, &txid) + .unwrap() + .is_none()); + } + assert_eq!( + persister.load().unwrap().wallets[&wallet.wallet_id] + .wallet_info + .balance + .total(), + 0 + ); + } + + /// A persisted lock is trusted only when it names the record it is keyed + /// under and that record's transaction really has that txid; otherwise the + /// record replays in its stored mempool context and no sweep runs. + #[tokio::test] + async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction( + &spend, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + let foreign = Txid::from_byte_array([24; 32]); + let lock_for = |txid| InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid, + ..Default::default() + }; + let mut forged_record = records.clone(); + forged_record + .iter_mut() + .find(|record| record.txid == spend.txid()) + .unwrap() + .txid = foreign; + let cases = [ + // The lock row is keyed under the record but locks another txid. + ( + "lock txid", + records.clone(), + spend.txid(), + lock_for(foreign), + ), + // Record and lock agree, but the record's transaction is another one. + ("record txid", forged_record, foreign, lock_for(foreign)), + ]; + for (case, records, key, lock) in cases { + let locks: BTreeMap = [(key, lock)].into_iter().collect(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks).unwrap(); + let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions + .values() + .any(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "{case}: a mismatched lock must not upgrade any record" + ); + assert!( + transactions.contains_key(&spend.txid()), + "{case}: the spend must still replay in its stored context" + ); + } + } + + /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. + fn replay_record( + parents: &[Txid], + value: u64, + context: TransactionContext, + ) -> TransactionRecord { + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + + let transaction = Transaction { + version: 1, + lock_time: 0, + input: parents + .iter() + .map(|parent| TxIn { + previous_output: OutPoint::new(*parent, 0), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + fn in_block(height: u32, position: Option) -> TransactionContext { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::BlockInfo; + + let block = BlockInfo::new(height, dashcore::BlockHash::all_zeros(), height); + TransactionContext::InBlock(position.map_or(block, |p| block.with_position(p))) + } + + fn replayed_txids(records: Vec) -> Vec { + replay_order(records).into_iter().map(|r| r.txid).collect() + } + + /// An unconfirmed child whose txid sorts ahead of its parent's still + /// replays after it, so its input reserves the parent's output. + #[test] + fn should_replay_unconfirmed_parent_before_its_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([1; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = (0..) + .map(|value| replay_record(&[parent.txid], value, TransactionContext::Mempool)) + .find(|child| child.txid < parent.txid) + .unwrap(); + let expected = vec![parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, parent]), expected); + } + + /// A parent whose stored record is still mempool replays ahead of a child + /// already recorded as confirmed. + #[test] + fn should_replay_mempool_parent_before_its_confirmed_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([2; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = replay_record(&[parent.txid], 900, in_block(50, Some(3))); + let unrelated = replay_record(&[Txid::from_byte_array([3; 32])], 700, in_block(40, None)); + let expected = vec![unrelated.txid, parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, unrelated, parent]), expected); + } + + /// Independent records follow chain order: height, then in-block + /// position, then unconfirmed. + #[test] + fn should_order_independent_records_by_height_then_block_position() { + use dashcore::hashes::Hash; + + let funding = |marker| [Txid::from_byte_array([marker; 32])]; + let pending = replay_record(&funding(4), 1, TransactionContext::Mempool); + let late_second = replay_record(&funding(5), 2, in_block(10, Some(2))); + let late_first = replay_record(&funding(6), 3, in_block(10, Some(1))); + let early = replay_record(&funding(7), 4, in_block(9, Some(5))); + let expected = vec![early.txid, late_first.txid, late_second.txid, pending.txid]; + + assert_eq!( + replayed_txids(vec![pending, late_second, late_first, early]), + expected + ); + } + + /// Within one block, in-block position decides: a spend follows the + /// funding transaction it spends, and unrelated transactions keep their + /// place around the pair. + #[test] + fn should_keep_block_position_order_for_same_block_spends() { + use dashcore::hashes::Hash; + + let parent = replay_record(&[Txid::from_byte_array([9; 32])], 10, in_block(20, Some(1))); + let child = replay_record(&[parent.txid], 9, in_block(20, Some(2))); + let before = replay_record(&[Txid::from_byte_array([10; 32])], 8, in_block(20, Some(0))); + let after = replay_record(&[Txid::from_byte_array([11; 32])], 7, in_block(20, Some(3))); + let expected = vec![before.txid, parent.txid, child.txid, after.txid]; + + assert_eq!(replayed_txids(vec![after, child, before, parent]), expected); + } + + /// Records that name each other as parents (impossible for real txids) + /// still all replay, after everything that is ready. + #[test] + fn should_keep_every_record_of_a_dependency_cycle() { + use dashcore::hashes::Hash; + + let (a, b) = ( + Txid::from_byte_array([0xAA; 32]), + Txid::from_byte_array([0xBB; 32]), + ); + let mut first = replay_record(&[b], 1, TransactionContext::Mempool); + first.txid = a; + let mut second = replay_record(&[a], 2, TransactionContext::Mempool); + second.txid = b; + let ready = replay_record( + &[Txid::from_byte_array([8; 32])], + 3, + TransactionContext::Mempool, + ); + let expected = vec![ready.txid, a, b]; + + assert_eq!(replayed_txids(vec![second, first, ready]), expected); + } } 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 7213558f3b4..d7ab594edcd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -653,6 +653,10 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ "platform_payment", ]; +/// Database label of `AccountType::DashpayExternalAccount`; SQL that +/// singles out contact watch-only rows binds this instead of a literal. +pub(crate) const DASHPAY_EXTERNAL_LABEL: &str = "dashpay_external"; + /// Stable database label for an `AccountType` variant (the `Debug` impl is not /// a stable format; this match is the contract). An added upstream variant /// fails this match's exhaustiveness check at compile time. @@ -715,7 +719,7 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' AccountType::ProviderOperatorKeys => "provider_operator", AccountType::ProviderPlatformKeys => "provider_platform", AccountType::DashpayReceivingFunds { .. } => "dashpay_receiving", - AccountType::DashpayExternalAccount { .. } => "dashpay_external", + AccountType::DashpayExternalAccount { .. } => DASHPAY_EXTERNAL_LABEL, AccountType::PlatformPayment { .. } => "platform_payment", } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index f437755606a..8b08d33e53c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -24,9 +24,11 @@ use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; use { - dashcore::OutPoint, platform_wallet::changeset::AssetLockEntry, - platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, rusqlite::Connection, - std::collections::BTreeMap, + dashcore::{OutPoint, Txid}, + platform_wallet::changeset::AssetLockEntry, + platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, + rusqlite::Connection, + std::collections::{BTreeMap, HashSet}, }; use crate::sqlite::schema::blob::impl_persistable_blob; @@ -196,6 +198,30 @@ pub fn apply( Ok(()) } +/// Remove replay losers from the resumable lifecycle in the Core repair transaction. +pub(crate) fn remove_swept( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txids: &HashSet, +) -> Result<(), WalletStorageError> { + let mut changes = AssetLockChangeSet::default(); + { + let mut stmt = tx.prepare( + "SELECT length(outpoint), outpoint FROM asset_locks WHERE wallet_id = ?1 AND status != 'consumed'", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + let bytes: Vec = row.get(1)?; + let outpoint = blob::decode_outpoint(&bytes)?; + if txids.contains(&outpoint.txid) { + changes.removed.insert(outpoint); + } + } + } + apply(tx, wallet_id, &changes) +} + /// Test-only drift guard for the `asset_locks.status` TEXT-column /// domain **as the writer sees it** (production code never reads this /// — the writer maps through [`status_str`] and the on-disk CHECK 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 new file mode 100644 index 00000000000..a57c0992695 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -0,0 +1,749 @@ +//! Wallet accounting repaired from historical owned outputs, independent of live UTXOs. + +use std::collections::{BTreeMap, HashSet}; + +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, ScriptBuf, Txid}; +use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionRecord, +}; +use key_wallet::transaction_checking::TransactionContext; +use platform_wallet::changeset::{is_owned, wallet_accounting, CoreChangeSet}; +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 crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::LoadCtx; +use crate::sqlite::util::safe_cast::i64_to_u64; + +/// Preserve proven ownership when a partial account snapshot replaces the wallet record. +pub(super) fn preserve_known_details( + tx: &Transaction<'_>, + wallet_id: &WalletId, + incoming: &TransactionRecord, +) -> Result { + let mut merged = incoming.clone(); + let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { + return Ok(merged); + }; + // 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() { + return Err(WalletStorageError::TransactionBodyConflict { + wallet_id: *wallet_id, + txid: incoming.txid, + }); + } + merged.transaction = previous.transaction; + let mut inputs: BTreeMap<_, _> = previous + .input_details + .into_iter() + .map(|d| (d.index, d)) + .collect(); + for detail in &incoming.input_details { + inputs.insert(detail.index, detail.clone()); + } + let mut outputs: BTreeMap<_, _> = previous + .output_details + .into_iter() + .map(|d| (d.index, d)) + .collect(); + for detail in &incoming.output_details { + let keep_previous = outputs + .get(&detail.index) + .is_some_and(|old| is_owned(old.role)) + && !is_owned(detail.role); + if !keep_previous { + outputs.insert(detail.index, detail.clone()); + } + } + merged.input_details = inputs.into_values().collect(); + merged.output_details = outputs.into_values().collect(); + Ok(merged) +} + +/// Read a stored record strictly: corrupt history is an error, never skipped. +fn prior_record( + conn: &Connection, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + core_state::get_tx_record(conn, wallet_id, txid, &LoadCtx::strict()) +} + +/// Index raw inputs independently of when their ownership becomes known. +pub(super) fn index_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) \ + VALUES (?1, ?2, ?3)", + )?; + for input in &record.transaction.input { + stmt.execute(params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode_outpoint(&input.previous_output)?, + ])?; + } + Ok(()) +} + +/// Repair changed records and consumers of newly materialized historical outputs. +pub(super) fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + let mut affected: HashSet = cs.records.iter().map(|r| r.txid).collect(); + 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)?); + } + } + if affected.is_empty() { + return Ok(()); + } + let network = network(tx, wallet_id)?; + for txid in affected { + repair_record(tx, wallet_id, &txid, network)?; + } + Ok(()) +} + +fn network( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let label: String = tx.query_row( + "SELECT network FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |r| r.get(0), + )?; + wallets::parse_network(&label).ok_or_else(|| WalletStorageError::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) +} + +fn owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let encoded = blob::encode_outpoint(outpoint)?; + let key = params![wallet_id.as_slice(), encoded]; + // Gate the script length in its own statement: SQLite evaluates every + // result column before a row is returned. + let Some((value, len)) = tx + .prepare_cached( + "SELECT value, length(script) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .optional()? + else { + return Ok(None); + }; + let value = i64_to_u64("core_utxos.value", value)?; + blob::check_size(len)?; + let script: Vec = tx + .prepare_cached( + "SELECT script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )? + .query_row(key, |row| row.get(0))?; + if contact_only_script(tx, wallet_id, &script)? { + return Ok(None); + } + let address = Address::from_script(&ScriptBuf::from_bytes(script), network)?; + Ok(Some((value, address))) +} + +/// Whether `script` is tracked only by a contact's watch-only (DashPay external) chain. +pub(crate) fn contact_only_script( + conn: &Connection, + wallet_id: &WalletId, + script: &[u8], +) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) \ + AND NOT EXISTS(SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 AND account_type != ?3)", + params![wallet_id.as_slice(), script, DASHPAY_EXTERNAL_LABEL], + |r| r.get(0), + )?) +} + +fn repair_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, + network: dashcore::Network, +) -> Result<(), WalletStorageError> { + let Some(mut record) = prior_record(tx, wallet_id, txid)? else { + return Ok(()); + }; + let original = blob::encode(&record)?; + let mut inputs = BTreeMap::new(); + for detail in record.input_details.drain(..) { + if !contact_only_script(tx, wallet_id, detail.address.script_pubkey().as_bytes())? { + inputs.insert(detail.index, detail); + } + } + for (index, input) in record.transaction.input.iter().enumerate() { + if let Some((value, address)) = + owned_output(tx, wallet_id, &input.previous_output, network)? + { + inputs.insert( + index as u32, + InputDetail { + index: index as u32, + value, + address, + }, + ); + // Stale mempool rows cannot overrule a later sweep's release. + if !matches!(record.context, TransactionContext::Mempool) { + // Record the spender so the mark stays attributable and + // reversible; an existing claim by another spender stands. + // TODO(release-repair-spends-after-reorg): release rows whose + // `spent_in_txid` spender is reorged out and never re-mined; + // needs verification of how upstream downgrades a stored + // record's context on reorg. + tx.execute( + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() + ], + )?; + } + } + } + let mut outputs = BTreeMap::new(); + for mut detail in record.output_details.drain(..) { + if let Some(address) = &detail.address { + if contact_only_script(tx, wallet_id, address.script_pubkey().as_bytes())? { + detail.role = OutputRole::Sent; + } + } + outputs.insert(detail.index, detail); + } + for (index, output) in record.transaction.output.iter().enumerate() { + let index = index as u32; + if let Some((_, address)) = owned_output( + tx, + wallet_id, + &OutPoint { + txid: *txid, + vout: index, + }, + network, + )? { + let role = outputs.get(&index).map_or(OutputRole::Received, |d| { + if d.role == OutputRole::Change { + OutputRole::Change + } else { + OutputRole::Received + } + }); + outputs.insert( + index, + OutputDetail { + index, + role, + address: Some(address), + value: output.value, + }, + ); + } + } + // Empty metadata is not accounting evidence (e.g. confirmation-only placeholders). + if inputs.is_empty() && outputs.is_empty() { + return Ok(()); + } + record.input_details = inputs.into_values().collect(); + record.output_details = outputs.into_values().collect(); + // The live projection computes the same accounting, so repair never + // flips a row it just wrote; only the overflow policy differs. + let (net, direction) = wallet_accounting(&record); + record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: *txid, + value: net, + })?; + record.direction = direction; + let repaired = blob::encode(&record)?; + if repaired != original { + // Append-only: the first pre-repair blob is kept verbatim and never + // replaced, so a wrong repair can always be undone. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) \ + SELECT wallet_id, txid, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + repaired, + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ], + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use dashcore::address::Payload; + use dashcore::{PubkeyHash, Transaction as CoreTransaction, TxOut}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + use platform_wallet::changeset::wallet_direction; + use platform_wallet::test_support::fold_wallet_records; + + use super::*; + + const WALLET_ID: WalletId = [0x5Au8; 32]; + + #[test] + fn should_reject_oversize_owned_output_script_before_reading_it() { + const WALLET: WalletId = WALLET_ID; + let mut conn = wallet_db("testnet"); + + use rusqlite::limits::Limit; + + use crate::sqlite::conn::SQLITE_MAX_BLOB_BYTES; + + let outpoint = OutPoint::new(Txid::from_byte_array([0x42; 32]), 0); + // Over the connection's length cap, so reading the column itself + // fails: only a length-only pre-read reports it as oversize. + let script = vec![0u8; SQLITE_MAX_BLOB_BYTES as usize + 1]; + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + &WALLET[..], + blob::encode_outpoint(&outpoint).unwrap(), + script + ], + ) + .unwrap(); + drop(script); + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) + .unwrap(); + let tx = conn.transaction().unwrap(); + + let err = owned_output(&tx, &WALLET, &outpoint, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "got {err:?}" + ); + } + + fn wallet_db(network: &str) -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + // The schema CHECK rejects unknown labels; a corrupt or newer file may still carry one. + conn.pragma_update(None, "ignore_check_constraints", true) + .unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, 0)", + params![&WALLET_ID[..], network], + ) + .unwrap(); + conn + } + + fn record(output_values: &[u64], received: &[u64]) -> TransactionRecord { + let script = Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([7; 20])), + ) + .script_pubkey(); + let body = CoreTransaction { + version: 1, + lock_time: 0, + input: Vec::new(), + output: output_values + .iter() + .map(|&value| TxOut { + value, + script_pubkey: script.clone(), + }) + .collect(), + special_transaction_payload: None, + }; + let details = received + .iter() + .enumerate() + .map(|(index, &value)| OutputDetail { + index: index as u32, + role: OutputRole::Received, + address: None, + value, + }) + .collect(); + TransactionRecord::new( + body, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + details, + 0, + ) + } + + fn store(conn: &Connection, record: &TransactionRecord) { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![ + &WALLET_ID[..], + record.txid.as_byte_array().as_slice(), + blob::encode(record).unwrap() + ], + ) + .unwrap(); + } + + #[test] + fn should_report_a_body_conflict_for_the_same_txid_with_another_body() { + let mut conn = wallet_db("testnet"); + let stored = record(&[1_000], &[]); + store(&conn, &stored); + let mut incoming = record(&[2_000], &[]); + incoming.txid = stored.txid; + + let tx = conn.transaction().unwrap(); + let err = preserve_known_details(&tx, &WALLET_ID, &incoming).unwrap_err(); + + assert!( + matches!( + err, + WalletStorageError::TransactionBodyConflict { wallet_id, txid } + if wallet_id == WALLET_ID && txid == stored.txid + ), + "got {err:?}" + ); + } + + /// A peer can serve a known txid with BIP144 witnesses attached; the txid + /// does not commit to them, so the stored body stands and the flush goes on. + #[test] + fn should_keep_the_stored_body_when_a_same_txid_body_differs_only_in_witness() { + let mut conn = wallet_db("testnet"); + let mut stored = record(&[1_000], &[]); + stored.transaction.input.push(dashcore::TxIn::default()); + stored.txid = stored.transaction.txid(); + store(&conn, &stored); + let mut incoming = stored.clone(); + incoming.transaction.input[0].witness = dashcore::Witness::from_slice(&[[0xAB]]); + assert_eq!(incoming.transaction.txid(), stored.txid); + assert_ne!(incoming.transaction, stored.transaction); + + let tx = conn.transaction().unwrap(); + let merged = preserve_known_details(&tx, &WALLET_ID, &incoming).unwrap(); + + assert_eq!(merged.transaction, stored.transaction); + } + + #[test] + fn should_report_an_unknown_wallet_network_label() { + let mut conn = wallet_db("moonnet"); + let tx = conn.transaction().unwrap(); + + let err = network(&tx, &WALLET_ID).unwrap_err(); + + assert!( + matches!( + &err, + WalletStorageError::UnknownWalletNetwork { wallet_id, label } + if *wallet_id == WALLET_ID && label == "moonnet" + ), + "got {err:?}" + ); + } + + #[test] + fn should_report_a_net_amount_that_does_not_fit_i64() { + let mut conn = wallet_db("testnet"); + let stored = record(&[u64::MAX, u64::MAX], &[u64::MAX, u64::MAX]); + store(&conn, &stored); + let tx = conn.transaction().unwrap(); + + let err = + repair_record(&tx, &WALLET_ID, &stored.txid, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!( + err, + WalletStorageError::NetAmountOverflow { wallet_id, txid, value } + if wallet_id == WALLET_ID + && txid == stored.txid + && value == 2 * i128::from(u64::MAX) + ), + "got {err:?}" + ); + } + + /// Shared with the Swift SDK's `TransactionAccountingTests` direction + /// table; pins the rule repair takes from `platform_wallet`. + #[test] + fn should_classify_repaired_direction_like_the_swift_sdk() { + use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; + use TransactionType::{AssetLock, Standard}; + // (type, spends ours, has owned output, has external output, expected) + let cases = [ + (Standard, true, true, false, Internal), + (Standard, true, true, true, Outgoing), + (Standard, true, false, false, Outgoing), + (AssetLock, true, false, false, Internal), + (AssetLock, true, true, false, Internal), + (AssetLock, true, false, true, Outgoing), + (Standard, false, true, false, Incoming), + (TransactionType::CoinJoin, true, true, false, CoinJoin), + ]; + for (kind, spends_ours, has_ours, has_external, expected) in cases { + assert_eq!( + wallet_direction(kind, spends_ours, has_ours, has_external), + expected, + "{kind:?} spends_ours={spends_ours} has_ours={has_ours} has_external={has_external}" + ); + } + } + + const FUNDING: u64 = 100_000_000; + const LOCK_CREDIT: u64 = 60_000_000; + const LOCK_FEE: u64 = 1_000; + + fn address(byte: u8) -> Address { + Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), + ) + } + + fn bip44() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + /// The funding account's record upstream emits for a spend of one + /// wallet coin worth [`FUNDING`], classified the way upstream + /// `record_transaction` does. `outputs` are `(script, value, role)`. + fn funding_slice( + kind: TransactionType, + outputs: &[(ScriptBuf, u64, OutputRole)], + ) -> TransactionRecord { + let body = CoreTransaction { + version: 3, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([0x11; 32]), + vout: 0, + }, + ..Default::default() + }], + output: outputs + .iter() + .map(|(script, value, _)| TxOut { + value: *value, + script_pubkey: script.clone(), + }) + .collect(), + special_transaction_payload: None, + }; + let details: Vec = outputs + .iter() + .enumerate() + .map(|(index, (script, value, role))| OutputDetail { + index: index as u32, + role: *role, + address: Address::from_script(script, dashcore::Network::Testnet).ok(), + value: *value, + }) + .collect(); + let owned: u64 = details + .iter() + .filter(|d| is_owned(d.role)) + .map(|d| d.value) + .sum(); + let has_sent = details.iter().any(|d| d.role == OutputRole::Sent); + let direction = if !has_sent && owned > 0 { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + }; + TransactionRecord::new( + body, + bip44(), + TransactionContext::Mempool, + kind, + direction, + vec![InputDetail { + index: 0, + value: FUNDING, + address: address(1), + }], + details, + owned as i64 - FUNDING as i64, + ) + } + + /// The keys account's thin marker for an asset lock: `Internal`, no + /// details, net `+credit` (the OP_RETURN output's value). + fn keys_marker(funding: &TransactionRecord) -> TransactionRecord { + let credit = funding.transaction.output[0].value; + TransactionRecord::new( + funding.transaction.clone(), + AccountType::AssetLockAddressTopUp, + TransactionContext::Mempool, + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + credit as i64, + ) + } + + /// The live projection and the SQLite repair must agree on a row's + /// net and direction, or the row flips each time storage repairs + /// what the live path just wrote. Covers the asset-lock shapes (no + /// change, change, paying an external output) and a plain + /// cross-account transfer. + #[test] + fn should_repair_live_folded_records_without_changing_their_accounting() { + let burn = || ScriptBuf::new_op_return(&[]); + let change = FUNDING - LOCK_CREDIT - LOCK_FEE; + // With no change, everything but the fee is burned into credits. + let lock_no_change = funding_slice( + TransactionType::AssetLock, + &[(burn(), FUNDING - LOCK_FEE, OutputRole::Unspendable)], + ); + let lock_change = funding_slice( + TransactionType::AssetLock, + &[ + (burn(), LOCK_CREDIT, OutputRole::Unspendable), + (address(2).script_pubkey(), change, OutputRole::Change), + ], + ); + let lock_external = funding_slice( + TransactionType::AssetLock, + &[ + (burn(), LOCK_CREDIT, OutputRole::Unspendable), + ( + address(2).script_pubkey(), + change - 5_000, + OutputRole::Change, + ), + (address(9).script_pubkey(), 5_000, OutputRole::Sent), + ], + ); + // Output 0 lands on a second account of the same wallet, which the + // funding account's local view can only call `Sent`. + let transfer = funding_slice( + TransactionType::Standard, + &[( + address(3).script_pubkey(), + FUNDING - LOCK_FEE, + OutputRole::Sent, + )], + ); + let mut transfer_receiver = transfer.clone(); + transfer_receiver.account_type = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + transfer_receiver.direction = TransactionDirection::Incoming; + transfer_receiver.input_details.clear(); + transfer_receiver.output_details[0].role = OutputRole::Received; + transfer_receiver.net_amount = (FUNDING - LOCK_FEE) as i64; + + let lock_net = -((LOCK_CREDIT + LOCK_FEE) as i64); + let cases = [ + ( + "asset lock, no change", + vec![lock_no_change.clone(), keys_marker(&lock_no_change)], + TransactionDirection::Internal, + -(FUNDING as i64), + ), + ( + "asset lock, change", + vec![lock_change.clone(), keys_marker(&lock_change)], + TransactionDirection::Internal, + lock_net, + ), + ( + "asset lock, no change, funding slice alone", + vec![lock_no_change.clone()], + TransactionDirection::Internal, + -(FUNDING as i64), + ), + ( + "asset lock paying an external output", + vec![lock_external.clone(), keys_marker(&lock_external)], + TransactionDirection::Outgoing, + lock_net - 5_000, + ), + ( + "cross-account transfer", + vec![transfer, transfer_receiver], + TransactionDirection::Internal, + -(LOCK_FEE as i64), + ), + ]; + for (name, mut records, direction, net) in cases { + fold_wallet_records(&mut records); + assert_eq!(records.len(), 1, "{name}"); + let live = &records[0]; + assert_eq!(live.direction, direction, "{name}: live direction"); + assert_eq!(live.net_amount, net, "{name}: live net"); + + let mut conn = wallet_db("testnet"); + store(&conn, live); + let tx = conn.transaction().unwrap(); + repair_record(&tx, &WALLET_ID, &live.txid, dashcore::Network::Testnet).unwrap(); + let repaired = prior_record(&tx, &WALLET_ID, &live.txid).unwrap().unwrap(); + + assert_eq!( + repaired.direction, live.direction, + "{name}: repaired direction" + ); + assert_eq!(repaired.net_amount, live.net_amount, "{name}: repaired net"); + } + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 77d4808ed75..97d49725d60 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -7,17 +7,20 @@ use std::collections::{HashMap, HashSet}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use dashcore::ephemerealdata::chain_lock::ChainLock; +use dashcore::{hashes::Hash, OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; +use platform_wallet::changeset::changeset::{SweepBatch, UtxoCreditVerdict}; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; -use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; +use crate::sqlite::schema::core_history; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; +use crate::sqlite::schema::{asset_locks, blob}; // PUBLIC material only: core-chain state reaching `record_blob` / // `islock_blob` (transaction records + InstantLocks are public chain data). @@ -92,13 +95,14 @@ pub fn apply( finalized = excluded.finalized, \ record_blob = excluded.record_blob", )?; - for record in &cs.records { + for incoming in &cs.records { + let record = core_history::preserve_known_details(tx, wallet_id, incoming)?; let block_info = record.block_info(); let height = block_info.map(|b| i64::from(b.height())); let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); let block_time = block_info.map(|b| i64::from(b.timestamp())); let finalized = block_info.is_some(); - let payload = blob::encode(record)?; + let payload = blob::encode(&record)?; stmt.execute(params![ wallet_id.as_slice(), AsRef::<[u8]>::as_ref(&record.txid), @@ -108,6 +112,7 @@ pub fn apply( finalized, payload, ])?; + core_history::index_record(tx, wallet_id, &record)?; } } // `addresses_derived` is intentionally NOT persisted here — the pool @@ -144,7 +149,24 @@ pub fn apply( refresh the record itself to update its confirmation height" ); } - execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, false)?; + let spent = match cs.utxo_credit_verdicts.get(&utxo.outpoint) { + Some(UtxoCreditVerdict::ObservedSpent { .. } | UtxoCreditVerdict::Doomed) => true, + Some(UtxoCreditVerdict::Uncredited) => { + let prior_spent: Option = tx + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), blob::encode_outpoint(&utxo.outpoint)?], + |row| row.get(0), + ) + .optional()?; + let Some(prior_spent) = prior_spent else { + continue; + }; + prior_spent + } + None => false, + }; + execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, spent)?; } } if !cs.spent_utxos.is_empty() { @@ -227,6 +249,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } + core_history::apply(tx, wallet_id, cs)?; return Ok(()); } @@ -390,6 +413,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } + core_history::apply(tx, wallet_id, cs)?; Ok(()) } @@ -483,6 +507,72 @@ fn surviving_stored_input_claims( Ok(claims) } +/// Apply replay settlement without releasing surviving or unknown durable claims. +pub fn apply_replay_sweeps( + tx: &Transaction<'_>, + wallet_id: &WalletId, + mut sweeps: Vec, +) -> Result<(), WalletStorageError> { + use dashcore::hashes::Hash; + + let removed: HashSet<_> = sweeps + .iter() + .flat_map(|sweep| sweep.txids.iter().copied()) + .collect(); + let candidates: HashSet<_> = sweeps + .iter() + .flat_map(|sweep| sweep.released_outpoints.iter().copied()) + .collect(); + let mut held = HashMap::new(); + for outpoint in candidates { + let key = blob::encode_outpoint(&outpoint)?; + let length: Option> = tx.query_row( + "SELECT length(spent_in_txid) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 1", + params![wallet_id.as_slice(), &key[..]], |row| row.get(0), + ).optional()?; + let Some(length) = length else { continue }; + if let Some(length) = length { + blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; + } + let (claim, height): (Option>, Option) = tx.query_row( + "SELECT spent_in_txid, winner_mined_height FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..]], |row| Ok((row.get(0)?, row.get(1)?)), + )?; + // A NULL claimant is an unknown durable spend, not evidence that the coin is free. + if claim + .as_deref() + .map(Txid::from_slice) + .transpose()? + .is_none_or(|claimant| !removed.contains(&claimant)) + { + held.insert(outpoint, (claim, height)); + } + } + for sweep in &mut sweeps { + sweep + .released_outpoints + .retain(|outpoint| !held.contains_key(outpoint)); + } + asset_locks::remove_swept(tx, wallet_id, &removed)?; + apply( + tx, + wallet_id, + &CoreChangeSet { + sweeps, + ..Default::default() + }, + )?; + // The generic sweep attributes held inputs to its winner; retain the original provenance. + for (outpoint, (claim, height)) in held { + let key = blob::encode_outpoint(&outpoint)?; + tx.execute( + "UPDATE core_utxos SET spent = 1, spent_in_txid = ?3, winner_mined_height = ?4 WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..], claim, height], + )?; + } + Ok(()) +} + /// Delete a swept transaction's row and outputs, then resolve the coins it /// claimed to spend. /// @@ -875,6 +965,32 @@ fn upsert_sync_state( Ok(()) } +/// Durable spend guards include recordless claims and unmaterialized sweep placeholders. +pub(crate) fn load_spent_claims( + conn: &Connection, + wallet_id: &WalletId, +) -> Result)>, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT length(outpoint), outpoint, length(spent_in_txid), spent_in_txid \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut outpoints = Vec::new(); + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + let bytes: Vec = row.get(1)?; + let claimant = if let Some(length) = row.get::<_, Option>(2)? { + blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; + let raw: Vec = row.get(3)?; + Some(Txid::from_slice(&raw)?) + } else { + None + }; + outpoints.push((blob::decode_outpoint(&bytes)?, claimant)); + } + Ok(outpoints) +} + /// Bulk-reconstruct the keyless [`CoreChangeSet`] projection for one wallet /// from the `core_*` tables, plus the per-outpoint owning-account side channel. /// PUBLIC material only; mints no `Wallet`. `network` (from `wallets`) turns a @@ -985,6 +1101,11 @@ pub fn load_state( let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; let height = transaction_heights.get(&outpoint.txid).copied().flatten(); let script = dashcore::ScriptBuf::from_bytes(script_bytes); + // A contact's watch-only output is never ours to spend, whatever + // an older build recorded; the fallback would make it spendable. + if core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { + continue; + } if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { utxo_accounts.insert(outpoint, owner); } @@ -1339,6 +1460,7 @@ pub fn list_unspent_utxos( #[cfg(test)] mod tests { use super::*; + use crate::sqlite::migrations::{self, rewind_to_v018}; use dashcore::address::Payload; use dashcore::hashes::Hash; use dashcore::{BlockHash, OutPoint, PubkeyHash, Transaction, TxOut, Txid}; @@ -1393,6 +1515,709 @@ mod tests { } } + #[test] + fn should_repair_history_when_spent_funding_arrives_late() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAB; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let funding = sample_utxo(Txid::from_byte_array([0x31; 32]), 100, true); + let mut spending = transaction_record( + Txid::from_byte_array([0x32; 32]), + TransactionContext::InBlock(BlockInfo::new(101, BlockHash::all_zeros(), 123)), + ); + spending.transaction.input.push(dashcore::TxIn { + previous_output: funding.outpoint, + script_sig: dashcore::ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::new(), + }); + spending.transaction.output.push(TxOut { + value: 90_000, + script_pubkey: funding.txout.script_pubkey.clone(), + }); + spending.txid = spending.transaction.txid(); + spending.net_amount = 90_000; + let mut change = sample_utxo(spending.txid, 101, true); + change.txout.value = 90_000; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spending.clone()], + new_utxos: vec![change], + ..Default::default() + }, + ) + .unwrap(); + let other_wallet = [0xCD; 32]; + tx.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&other_wallet[..]], + ) + .unwrap(); + apply( + &tx, + &other_wallet, + &CoreChangeSet { + new_utxos: vec![funding.clone()], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + get_tx_record(&tx, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .net_amount, + 90_000, + "another wallet's funding must not affect this history" + ); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![funding.clone()], + ..Default::default() + }, + ) + .unwrap(); + let repaired = get_tx_record(&tx, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, -60_000); + assert_eq!(repaired.direction, TransactionDirection::Internal); + assert_eq!(repaired.input_details.len(), 1); + assert_eq!(repaired.block_info().unwrap().height(), 101); + let spent: bool = tx + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert!(spent, "late funding must not resurrect the spent coin"); + let spender: Option> = tx + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + spender.as_deref(), + Some(AsRef::<[u8]>::as_ref(&spending.txid)), + "a repair mark names its spender so it can be reverted" + ); + let original: Vec = tx + .query_row( + "SELECT record_blob FROM core_transaction_record_originals \ + WHERE wallet_id = ?1 AND txid = ?2", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&spending.txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + blob::decode::(&original) + .unwrap() + .net_amount, + 90_000, + "the pre-repair record is kept verbatim" + ); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spending.clone()], + new_utxos: vec![funding], + ..Default::default() + }, + ) + .unwrap(); + let replayed = get_tx_record(&tx, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(replayed.net_amount, -60_000); + assert_eq!(replayed.input_details.len(), 1); + } + + #[test] + fn should_repair_existing_history_during_migration() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAC; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let funding = sample_utxo(Txid::from_byte_array([0x41; 32]), 100, true); + let mut spending = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + spending.transaction.input.push(dashcore::TxIn { + previous_output: funding.outpoint, + script_sig: dashcore::ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::new(), + }); + spending.txid = spending.transaction.txid(); + spending.net_amount = 0; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spending.clone()], + new_utxos: vec![funding.clone()], + ..Default::default() + }, + ) + .unwrap(); + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + blob::encode(&spending).unwrap(), + &wallet_id[..], + AsRef::<[u8]>::as_ref(&spending.txid) + ], + ) + .unwrap(); + tx.execute_batch( + "DROP TABLE IF EXISTS core_transaction_inputs; \ + DROP TABLE IF EXISTS core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); + tx.commit().unwrap(); + migrations::run(&mut conn).unwrap(); + let repaired = get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, -150_000); + assert_eq!(repaired.input_details.len(), 1); + let spent: bool = conn + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert!( + !spent, + "a stale mempool attempt cannot undo a released coin during migration" + ); + migrations::run(&mut conn).unwrap(); + assert_eq!( + get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .net_amount, + -150_000 + ); + let original: Vec = conn + .query_row( + "SELECT record_blob FROM core_transaction_record_originals \ + WHERE wallet_id = ?1 AND txid = ?2", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&spending.txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + original, + blob::encode(&spending).unwrap(), + "V019 keeps the record it rewrote" + ); + } + + /// A V018 database whose wallet 0xAD has one corrupt record at `height`. + fn v018_with_corrupt_record(height: Option) -> (Connection, [u8; 32]) { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + rewind_to_v018(&conn); + let wallet_id = [0xADu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 100)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 500, 500)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, ?3, 0, ?4)", + params![&wallet_id[..], &[0u8; 32][..], height, &[0xffu8][..]], + ) + .unwrap(); + (conn, wallet_id) + } + + #[test] + fn should_fail_history_migration_on_corrupt_unconfirmed_record() { + let (mut conn, _) = v018_with_corrupt_record(None); + assert!( + migrations::run(&mut conn).is_err(), + "a resync cannot restore an unconfirmed record, so it must not be dropped" + ); + let tables: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE name = 'core_transaction_inputs'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tables, 0); + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(version, 18); + } + + #[test] + fn should_drop_corrupt_confirmed_record_and_rescan_its_wallet() { + let (mut conn, wallet_id) = v018_with_corrupt_record(Some(150)); + let kept = transaction_record( + Txid::from_byte_array([0x11; 32]), + TransactionContext::InBlock(BlockInfo::new(160, BlockHash::all_zeros(), 1)), + ); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 160, 1, ?3)", + params![ + &wallet_id[..], + AsRef::<[u8]>::as_ref(&kept.txid), + blob::encode(&kept).unwrap() + ], + ) + .unwrap(); + migrations::run(&mut conn).expect("a re-deliverable corrupt record must not block opening"); + let rows: Vec> = conn + .prepare_cached("SELECT txid FROM core_transactions WHERE wallet_id = ?1") + .unwrap() + .query_map(params![&wallet_id[..]], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows, vec![AsRef::<[u8]>::as_ref(&kept.txid).to_vec()]); + let (last_processed, synced): (i64, i64) = conn + .query_row( + "SELECT last_processed_height, synced_height FROM core_sync_state \ + WHERE wallet_id = ?1", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!( + synced, 99, + "the filter checkpoint rewinds to just below birth" + ); + assert_eq!( + last_processed, 500, + "the processed watermark stays monotonic" + ); + let (cs, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(cs.synced_height, Some(99), "load hands the rewind to SPV"); + } + + #[test] + fn should_reject_corrupt_prior_record_when_storing_the_transaction() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xA9u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![ + &wallet_id[..], + AsRef::<[u8]>::as_ref(&record.txid), + &[0xffu8][..] + ], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + assert!( + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record], + ..Default::default() + }, + ) + .is_err(), + "normal operation treats corrupt stored history as an error" + ); + } + + #[test] + fn should_mark_observed_spent_and_doomed_outputs_spent() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAEu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + for (marker, verdict) in [ + (1, UtxoCreditVerdict::ObservedSpent { height: 101 }), + (2, UtxoCreditVerdict::Doomed), + ] { + let utxo = sample_utxo(Txid::from_byte_array([marker; 32]), 100, true); + let outpoint = utxo.outpoint; + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo], + utxo_credit_verdicts: [(outpoint, verdict)].into(), + ..Default::default() + }, + ) + .unwrap(); + let spent: bool = tx + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(&outpoint).unwrap()], + |row| row.get(0), + ) + .unwrap(); + assert!(spent, "engine did not credit {verdict:?}"); + } + } + + #[test] + fn should_keep_prior_spent_flag_for_uncredited_outputs() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xA8u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + let stored_spent = |outpoint: &OutPoint| -> Option { + tx.query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| row.get(0), + ) + .optional() + .unwrap() + }; + let uncredited = |utxo: Utxo| CoreChangeSet { + utxo_credit_verdicts: [(utxo.outpoint, UtxoCreditVerdict::Uncredited)].into(), + new_utxos: vec![utxo], + ..Default::default() + }; + + let fresh = sample_utxo(Txid::from_byte_array([3; 32]), 100, true); + apply(&tx, &wallet_id, &uncredited(fresh.clone())).unwrap(); + assert_eq!( + stored_spent(&fresh.outpoint), + None, + "never materialize an uncredited output" + ); + + let known = sample_utxo(Txid::from_byte_array([4; 32]), 100, true); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![known.clone()], + utxo_credit_verdicts: [(known.outpoint, UtxoCreditVerdict::Doomed)].into(), + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &uncredited(known.clone())).unwrap(); + assert_eq!( + stored_spent(&known.outpoint), + Some(true), + "an uncredited replay keeps the spend" + ); + } + + #[test] + fn should_exclude_historical_contact_outputs_from_accounting() { + use key_wallet::managed_account::transaction_record::{OutputDetail, OutputRole}; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xAFu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let mut record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + let mut output = sample_utxo(record.txid, 0, false); + record.transaction.output = vec![output.txout.clone()]; + record.txid = record.transaction.txid(); + record.output_details = vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(output.address.clone()), + value: output.value(), + }]; + record.net_amount = output.value() as i64; + output.outpoint.txid = record.txid; + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], output.txout.script_pubkey.as_bytes()], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + new_utxos: vec![output], + ..Default::default() + }, + ) + .unwrap(); + let repaired = get_tx_record(&tx, &wallet_id, &record.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, 0); + assert_eq!(repaired.output_details[0].role, OutputRole::Sent); + } + + /// Store one contact-only unspent row (a pre-fix build persisted the + /// contact's coins) next to one of our own. Returns both outpoints. + fn stage_contact_only_utxo(conn: &Connection, wallet_id: &[u8; 32]) -> (OutPoint, OutPoint) { + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let contact = sample_utxo(Txid::from_byte_array([0x51; 32]), 100, true); + let mut own = sample_utxo(Txid::from_byte_array([0x52; 32]), 100, true); + own.address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0x24u8; 20])), + ); + own.txout.script_pubkey = own.address.script_pubkey(); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], contact.txout.script_pubkey.as_bytes()], + ) + .unwrap(); + let (contact_outpoint, own_outpoint) = (contact.outpoint, own.outpoint); + let tx = conn.unchecked_transaction().unwrap(); + apply( + &tx, + wallet_id, + &CoreChangeSet { + new_utxos: vec![contact, own], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + (contact_outpoint, own_outpoint) + } + + #[test] + fn should_not_load_contact_only_outputs_as_spendable() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB1u8; 32]; + let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); + let (cs, owners) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded: Vec<_> = cs.new_utxos.iter().map(|u| u.outpoint).collect(); + assert_eq!(loaded, vec![own], "a contact's coin is not ours to spend"); + assert!(!owners.contains_key(&contact)); + } + + #[test] + fn should_keep_contact_only_rows_but_exclude_them_after_history_migration() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB2u8; 32]; + let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); + rewind_to_v018(&conn); + migrations::run(&mut conn).unwrap(); + let unspent_rows = |outpoint: &OutPoint| -> i64 { + conn.query_row( + "SELECT count(*) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 0", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + unspent_rows(&contact), + 1, + "V019 must not destroy stored rows" + ); + assert_eq!(unspent_rows(&own), 1); + let (cs, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded: Vec<_> = cs.new_utxos.iter().map(|u| u.outpoint).collect(); + assert_eq!( + loaded, + vec![own], + "the kept row still never becomes spendable" + ); + } + + #[test] + fn should_preserve_known_inputs_when_replay_has_no_historical_txo() { + use key_wallet::managed_account::transaction_record::InputDetail; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB0u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let funding = sample_utxo(Txid::from_byte_array([0x42; 32]), 100, true); + let mut record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + record.transaction.input.push(dashcore::TxIn { + previous_output: funding.outpoint, + script_sig: dashcore::ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::new(), + }); + record.txid = record.transaction.txid(); + record.input_details = vec![InputDetail { + index: 0, + value: funding.value(), + address: funding.address, + }]; + record.net_amount = -150_000; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + ..Default::default() + }, + ) + .unwrap(); + record.input_details.clear(); + record.net_amount = 0; + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + ..Default::default() + }, + ) + .unwrap(); + let repaired = get_tx_record(&tx, &wallet_id, &record.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(repaired.net_amount, -150_000); + assert_eq!(repaired.input_details.len(), 1); + } + + #[test] + fn should_preserve_spendability_for_unknown_credit_verdicts() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id = [0xB1u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let utxo = sample_utxo(Txid::from_byte_array([0x43; 32]), 100, true); + let cs = CoreChangeSet { + new_utxos: vec![utxo.clone()], + utxo_credit_verdicts: [(utxo.outpoint, UtxoCreditVerdict::Uncredited)].into(), + ..Default::default() + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + let count: i64 = tx + .query_row("SELECT count(*) FROM core_utxos", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "unknown credit cannot create a spendable coin"); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + let spent: bool = tx + .query_row("SELECT spent FROM core_utxos", [], |r| r.get(0)) + .unwrap(); + assert!(!spent, "unknown credit cannot invent a spend"); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + spent_utxos: vec![utxo], + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + let spent: bool = tx + .query_row("SELECT spent FROM core_utxos", [], |r| r.get(0)) + .unwrap(); + assert!(spent, "unknown credit cannot clear a prior spend"); + } + fn sample_chain_lock(height: u32) -> ChainLock { ChainLock { block_height: height, @@ -1408,7 +2233,7 @@ mod tests { #[test] fn load_state_rejects_oversize_instant_lock_txid() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let w = [0xABu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1436,7 +2261,7 @@ mod tests { #[test] fn load_state_reconciles_utxo_height_from_confirmed_transaction_record() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x42u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1504,7 +2329,7 @@ mod tests { #[test] fn load_state_restores_confirmed_recordless_utxo_height() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x44u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1547,7 +2372,7 @@ mod tests { #[test] fn height_only_placeholder_does_not_regress() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x49u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1597,7 +2422,7 @@ mod tests { #[test] fn load_state_treats_height_zero_as_confirmed() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x4Au8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1632,7 +2457,7 @@ mod tests { #[test] fn transaction_record_always_overrides_height_only_placeholder() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x45u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1732,7 +2557,7 @@ mod tests { #[test] fn load_state_defaults_utxo_without_transaction_record_to_unconfirmed() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x43u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1790,7 +2615,7 @@ mod tests { #[test] fn load_state_tolerates_transaction_blob_txid_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x46u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1845,7 +2670,7 @@ mod tests { fn load_state_blob_height_wins_over_drifted_typed_column_in_either_scan_order() { for (case, typed_byte) in [0x10, 0xF0].into_iter().enumerate() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x50 + case as u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) \ @@ -1912,7 +2737,7 @@ mod tests { #[test] fn load_state_tolerates_transaction_blob_height_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x47u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1976,7 +2801,7 @@ mod tests { #[test] fn get_tx_record_declines_a_txid_drifted_row_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x4Bu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2032,7 +2857,7 @@ mod tests { #[test] fn get_tx_record_tolerates_blob_height_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x4Cu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2090,7 +2915,7 @@ mod tests { #[test] fn load_used_addresses_wraps_address_error_as_address_decode() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let w = [0x99u8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2125,7 +2950,7 @@ mod tests { #[test] fn apply_refuses_an_empty_script_on_a_new_utxo() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x5Bu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2160,7 +2985,7 @@ mod tests { #[test] fn apply_refuses_an_empty_script_on_a_synthetic_spent_row() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x5Cu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -2192,7 +3017,7 @@ mod tests { #[test] fn apply_still_marks_an_existing_utxo_spent() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::sqlite::migrations::run(&mut conn).unwrap(); + migrations::run(&mut conn).unwrap(); let wallet_id = [0x5Du8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 88bff87e377..ad4bdbb387b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -10,6 +10,7 @@ pub mod accounts; pub mod asset_locks; pub mod blob; pub mod contacts; +pub(crate) mod core_history; pub mod core_pool; pub mod core_state; pub mod dashpay; diff --git a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs index 79e7ea60238..4954aae4323 100644 --- a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; +use dashcore::hashes::Hash; use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; use platform_wallet_storage::sqlite::error::{AutoBackupOperation, WalletStorageError}; use platform_wallet_storage::sqlite::util::safe_cast::SafeCastTarget; @@ -150,6 +151,39 @@ fn tc_code_004_b_identity_index_variants_map_to_constraint_kind() { } } +/// History invariants are enforced in Rust on the write path: an incoming +/// record that contradicts stored history is a data fault, not a retryable +/// or engine failure. +#[test] +fn history_integrity_variants_map_to_constraint_kind() { + let txid = dashcore::Txid::from_byte_array([0x44; 32]); + let cases: Vec<(&str, WalletStorageError)> = vec![ + ( + "TransactionBodyConflict", + WalletStorageError::TransactionBodyConflict { + wallet_id: [0xAA; 32], + txid, + }, + ), + ( + "NetAmountOverflow", + WalletStorageError::NetAmountOverflow { + wallet_id: [0xAA; 32], + txid, + value: i128::from(i64::MIN) - 1, + }, + ), + ]; + for (label, err) in cases { + assert!(!err.is_transient(), "{label}: must not be transient"); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Constraint, + "{label}: trait-boundary kind must be Constraint" + ); + } +} + /// Every remaining fatal-but-not-constraint variant maps to `Fatal`. /// Spot-check enough variants to lock the table; the /// exhaustiveness is guarded by the wildcard-free invariant test. @@ -232,6 +266,13 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { "BlobDecode", WalletStorageError::BlobDecode { reason: "len" }, ), + ( + "UnknownWalletNetwork", + WalletStorageError::UnknownWalletNetwork { + wallet_id: [0xAA; 32], + label: "moonnet".into(), + }, + ), ( "ForeignKeysNotEnforced", WalletStorageError::ForeignKeysNotEnforced, 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 36d46cb6073..6884ea5dca3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -4,10 +4,10 @@ //! plus the boundary mapping of `FlushRetryable` into //! `PersistenceError::Backend`. //! -//! The check is a wildcard-free `match` with one arm per variant (no -//! `_`), so a new `WalletStorageError` variant fails to compile here -//! until it is classified — mirroring the matches in `error::is_transient` -//! / `error::error_kind_str`. +//! The check is a `match` with one arm per variant. The enum is +//! `#[non_exhaustive]`, so this external test needs a panicking `_` arm; +//! the compile-time guard lives in the wildcard-free matches of +//! `error::is_transient` / `error::error_kind_str`. use std::path::PathBuf; @@ -214,6 +214,19 @@ fn samples() -> Vec { typed_height: Some(100), blob_height: Some(101), }, + WalletStorageError::TransactionBodyConflict { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + }, + WalletStorageError::UnknownWalletNetwork { + wallet_id: [0x33; 32], + label: "moonnet".into(), + }, + WalletStorageError::NetAmountOverflow { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + value: i128::from(u64::MAX) * 2, + }, WalletStorageError::BlobTooLarge { len_bytes: 32 * 1024 * 1024, limit_bytes: 16 * 1024 * 1024, @@ -363,10 +376,8 @@ fn samples() -> Vec { #[test] fn tc_p2_005_is_transient_table() { fn classify(err: &WalletStorageError) -> (bool, &'static str) { - // Every arm asserts the expected (transient, kind_str) pair - // and returns it for the outer assertion. A new variant - // landing in WalletStorageError makes this match fail to - // compile until classified. + // Every arm returns the expected (transient, kind_str) pair + // for the outer assertion. match err { // SQLite path discriminates by inner ErrorCode — split // into busy / locked / other to mirror error_kind_str. @@ -437,6 +448,11 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::CoreTransactionEntryMismatch { .. } => { (false, "core_transaction_entry_mismatch") } + WalletStorageError::TransactionBodyConflict { .. } => { + (false, "transaction_body_conflict") + } + WalletStorageError::UnknownWalletNetwork { .. } => (false, "unknown_wallet_network"), + WalletStorageError::NetAmountOverflow { .. } => (false, "net_amount_overflow"), WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), @@ -494,6 +510,10 @@ fn tc_p2_005_is_transient_table() { (false, "empty_pool_address_script") } WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), + // `WalletStorageError` is `#[non_exhaustive]`, so this external test + // crate needs a catch-all arm. Exhaustiveness is enforced in-crate by + // the wildcard-free matches in `src/sqlite/error.rs`. + other => panic!("sample {other:?} has no expected classification"), } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index e341aa0df0d..7622036fc04 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -555,6 +555,13 @@ fn tc046_v014_purges_legacy_empty_script_spent_utxos() { /// must survive untouched. #[test] fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { + use dashcore::hashes::Hash; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use platform_wallet_storage::sqlite::schema::blob::encode; use rusqlite::params; let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); @@ -572,11 +579,28 @@ fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { params![wallet_id.as_slice()], ) .expect("insert wallet"); - let live_txid = [7u8; 32]; + let record = TransactionRecord::new( + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::IdentityRegistration, + TransactionContext::InBlock(BlockInfo::new(100, dashcore::BlockHash::all_zeros(), 123)), + TransactionType::Standard, + TransactionDirection::Incoming, + vec![], + vec![], + 0, + ); + let live_txid = record.txid.to_byte_array(); + let live_blob = encode(&record).expect("encode valid historical record"); conn.execute( "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, \ - finalized, record_blob) VALUES (?1, ?2, 100, NULL, NULL, 1, X'AA')", - params![wallet_id.as_slice(), live_txid.as_slice()], + finalized, record_blob) VALUES (?1, ?2, 100, NULL, NULL, 1, ?3)", + params![wallet_id.as_slice(), live_txid.as_slice(), &live_blob], ) .expect("insert live transaction"); let live_outpoint = [0x20u8; 37]; @@ -630,7 +654,7 @@ fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .expect("live transaction survives"); - assert_eq!((height, finalized, blob), (100, 1, vec![0xAA])); + assert_eq!((height, finalized, blob), (100, 1, live_blob)); // 7. The live UTXO survived, and its confirmation height was backfilled // onto a height-only `core_transactions` row. 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 282a2f5abf1..d27c6cce39b 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, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "91f2fab573900a41066b8d237a29b83ca94b2fc730702389ab9c088271da522f"; + "edecaf720a714fb2b689e3a3a416a2300a15a9c846138f417e6fb6affb745b1b"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "0dbcfb2ab8d8362a206c0ab948051028c7eafc640861a823c4c50f13b0602be8"; + "6023660fb488d9d3a98bb069bcb9950bdf125c3e8a8264f2a3dd3bd36a0844b8"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs new file mode 100644 index 00000000000..777a566958f --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -0,0 +1,1482 @@ +#![cfg(feature = "sqlite")] + +mod common; + +use dashcore::{ + address::Payload, bls_sig_utils::BLSSignature, ephemerealdata::chain_lock::ChainLock, + hashes::Hash, Address, BlockHash, Network, OutPoint, PubkeyHash, ScriptBuf, Transaction, TxIn, + TxOut, Txid, +}; +use key_wallet::{ + transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}, + wallet::{ + initialization::WalletAccountCreationOptions, + managed_wallet_info::{ + coin_selection::{CoinSelector, SelectionStrategy}, + fee::FeeRate, + wallet_info_interface::WalletInfoInterface, + }, + ManagedWalletInfo, Wallet, + }, + Utxo, +}; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn block(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) +} + +struct Fixture { + persister: SqlitePersister, + _dir: tempfile::TempDir, + wallet_id: [u8; 32], + funding: Transaction, + spent: OutPoint, + available: OutPoint, +} + +impl Fixture { + async fn new(spend_context: TransactionContext) -> Self { + Self::with_funding(block(100), spend_context).await + } + + async fn with_funding( + funding_context: TransactionContext, + spend_context: TransactionContext, + ) -> Self { + Self::build(funding_context, spend_context, true).await + } + + /// The funding transaction persisted only through its UTXOs: a + /// height-only `core_transactions` row with no record to replay. + async fn with_height_only_funding(spend_context: TransactionContext) -> Self { + let fixture = Self::build(block(100), spend_context, false).await; + let (height, has_record): (Option, bool) = fixture + .persister + .lock_conn_for_test() + .query_row( + "SELECT height, record_blob IS NOT NULL FROM core_transactions WHERE txid = ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!((height, has_record), (Some(100), false)); + fixture + } + + async fn build( + funding_context: TransactionContext, + spend_context: TransactionContext, + store_funding_record: bool, + ) -> Self { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([13; 32]), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }, + TxOut { + value: 20_000, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let spent = OutPoint::new(funding.txid(), 0); + let available = OutPoint::new(funding.txid(), 1); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let funding_result = info + .check_core_transaction(&funding, funding_context, &mut wallet, true, true) + .await; + let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + let spent_coin = info.accounts.standard_bip44_accounts[&0].utxos[&spent].clone(); + let spending_result = info + .check_core_transaction(&spending, spend_context, &mut wallet, true, true) + .await; + let mut records = funding_result.new_records; + records.extend(spending_result.new_records); + assert_eq!(records.len(), 2); + if !store_funding_record { + records.retain(|record| record.txid != funding.txid()); + } + let (persister, dir, path) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records, + new_utxos: coins, + spent_utxos: vec![spent_coin], + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + Self { + persister, + _dir: dir, + wallet_id: wallet.wallet_id, + funding, + spent, + available, + } + } + + fn load(&self) -> (Wallet, ManagedWalletInfo) { + let mut state = self.persister.load().unwrap(); + let restored = state.wallets.remove(&self.wallet_id).unwrap(); + (restored.wallet, restored.wallet_info) + } + + fn assert_spent_excluded(&self, info: &ManagedWalletInfo) { + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&self.spent)); + assert!(coins.contains_key(&self.available)); + assert_eq!(info.balance.total(), 20_000); + let selector = CoinSelector::new(SelectionStrategy::LargestFirst); + assert!(selector + .select_coins(coins.values(), 50_000, FeeRate::default(), 300) + .is_err()); + let selection = selector + .select_coins(coins.values(), 5_000, FeeRate::default(), 300) + .unwrap(); + assert_eq!(selection.selected.len(), 1); + assert_eq!(selection.selected[0].outpoint, self.available); + } + + fn assert_spent_stored(&self, expected: bool) { + let spent: bool = self + .persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE substr(outpoint, 2, 32) = ?1 AND value = 100000", + [self.spent.txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(spent, expected, "stored spent flag of the reserved input"); + } + + async fn redeliver(&self, wallet: &mut Wallet, info: &mut ManagedWalletInfo) { + let result = info + .check_core_transaction(&self.funding, block(100), wallet, true, true) + .await; + self.assert_spent_excluded(info); + self.persister + .store( + self.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let (_, reloaded) = self.load(); + self.assert_spent_excluded(&reloaded); + } +} + +#[tokio::test] +async fn should_reject_spent_output_after_reload_and_funding_redelivery() { + let fixture = Fixture::new(block(200)).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_keep_spent_output_excluded_after_finality_pruning() { + let fixture = Fixture::new(block(200)).await; + let (mut wallet, mut info) = fixture.load(); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty()); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_keep_spent_output_excluded_after_finality_pruning_with_height_only_funding() { + let fixture = Fixture::with_height_only_funding(block(200)).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty()); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_keep_unconfirmed_spend_reservation_with_height_only_funding() { + let fixture = Fixture::with_height_only_funding(TransactionContext::Mempool).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); +} + +#[tokio::test] +async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { + let fixture = Fixture::new(block(200)).await; + fixture + .persister + .lock_conn_for_test() + .execute("UPDATE core_utxos SET spent = 0", []) + .unwrap(); + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[tokio::test] +async fn should_not_release_inputs_reserved_by_unconfirmed_spend() { + let fixture = Fixture::new(TransactionContext::Mempool).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + assert!(!info.observed_spent_outpoints().contains_key(&fixture.spent)); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); +} + +#[tokio::test] +async fn should_keep_unconfirmed_funding_reserved_when_it_confirms_after_reload() { + let fixture = + Fixture::with_funding(TransactionContext::Mempool, TransactionContext::Mempool).await; + let (mut wallet, mut info) = fixture.load(); + assert!(!info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fixture.spent)); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); +} + +#[tokio::test] +async fn should_handle_funding_and_spend_in_the_same_block() { + let fixture = Fixture::new(block(100)).await; + let (mut wallet, mut info) = fixture.load(); + fixture.assert_spent_excluded(&info); + fixture.redeliver(&mut wallet, &mut info).await; +} + +#[test] +fn should_restore_without_an_async_runtime() { + let fixture = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(Fixture::new(block(200))); + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_on_managers_blocking_pool() { + let fixture = Fixture::new(block(200)).await; + tokio::task::spawn_blocking(move || { + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn should_restore_persisted_finality_before_advancing_sync_checkpoint() { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let fixture = Fixture::new(block(100)).await; + fixture + .persister + .store( + fixture.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let (mut wallet, mut info) = fixture.load(); + assert!(info.accounts.standard_bip44_accounts[&0] + .keys() + .transaction_is_finalized(&fixture.funding.txid())); + info.update_synced_height(300); + fixture.redeliver(&mut wallet, &mut info).await; +} + +/// A foreign P2PKH address no account of the test wallet derives. +fn foreign_address(marker: u8) -> Address { + Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([marker; 20])), + ) +} + +/// The persisted UTXO for `funding`'s output `vout`, as a sync would store it. +fn stored_utxo(funding: &Transaction, vout: u32, address: Address) -> Utxo { + Utxo { + outpoint: OutPoint::new(funding.txid(), vout), + txout: funding.output[vout as usize].clone(), + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +#[tokio::test] +async fn should_keep_replayed_output_only_in_its_owning_account() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip32_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip32_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + // vout 1: no pool row and no replay owner, so the first-account fallback + // must hold. vout 2: tracked only by a contact's watch-only chain. + let (unowned, contact) = (foreign_address(0x61), foreign_address(0x62)); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([14; 32]), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 70_000, + script_pubkey: address.script_pubkey(), + }, + TxOut { + value: 5_000, + script_pubkey: unowned.script_pubkey(), + }, + TxOut { + value: 9_000, + script_pubkey: contact.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let coin = OutPoint::new(funding.txid(), 0); + let fallback = OutPoint::new(funding.txid(), 1); + let contact_coin = OutPoint::new(funding.txid(), 2); + let result = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + let mut coins: Vec<_> = info.accounts.standard_bip32_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + assert_eq!(coins.len(), 1); + coins.push(stored_utxo(&funding, 1, unowned)); + coins.push(stored_utxo(&funding, 2, contact.clone())); + let (persister, _dir, _) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: coins, + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + // Without its pool row the loader cannot attribute the coin and parks + // it in the first funds account; replay then finds the real owner. + conn.execute( + "DELETE FROM core_address_pool WHERE script = ?1", + [address.script_pubkey().as_bytes()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + rusqlite::params![&wallet.wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + } + + let mut state = persister.load().unwrap(); + let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + let holders = |outpoint: &OutPoint| { + info.accounts + .all_funding_accounts() + .into_iter() + .filter(|account| account.utxos.contains_key(outpoint)) + .count() + }; + assert_eq!( + holders(&coin), + 1, + "one outpoint must live in exactly one account" + ); + assert!(info.accounts.standard_bip32_accounts[&0] + .utxos + .contains_key(&coin)); + assert!( + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fallback), + "an output replay cannot attribute keeps the first-account fallback" + ); + assert_eq!(holders(&contact_coin), 0, "a contact's coin is not ours"); + assert_eq!(info.balance.total(), 75_000); + assert_eq!(info.get_spendable_utxos().len(), 2); + let stored: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT count(*) FROM core_utxos WHERE spent = 0", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, 3, "load must not delete stored rows"); +} + +#[tokio::test] +async fn should_drop_replay_credit_for_contact_only_script() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let [contact, ours]: [Address; 2] = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_addresses(Some(&xpub), 2, true) + .unwrap() + .try_into() + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([16; 32]), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 40_000, + script_pubkey: contact.script_pubkey(), + }, + TxOut { + value: 7_000, + script_pubkey: ours.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let contact_coin = OutPoint::new(funding.txid(), 0); + let our_coin = OutPoint::new(funding.txid(), 1); + let result = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + assert_eq!(coins.len(), 2); + let (persister, _dir, _) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: coins, + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + // The store tracks the script only on a contact's watch-only chain, + // yet the rebuilt funds account still derives it, so replay credits it. + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_address_pool WHERE script = ?1", + [contact.script_pubkey().as_bytes()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + rusqlite::params![&wallet.wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + } + + let mut state = persister.load().unwrap(); + let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + assert!( + info.accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&contact_coin)), + "a replay-credited contact coin must not survive load" + ); + assert!(info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&our_coin)); + assert_eq!(info.balance.total(), 7_000); +} + +#[tokio::test] +async fn should_restore_extra_input_released_by_a_persisted_conflict_after_restart() { + assert_conflict_restart(ConflictCase::Released).await; +} + +#[tokio::test] +async fn should_restore_released_input_with_height_only_funding_after_restart() { + assert_conflict_restart(ConflictCase::HeightOnlyFunding).await; +} + +#[tokio::test] +async fn should_keep_extra_input_reserved_by_a_surviving_spend_after_restart() { + assert_conflict_restart(ConflictCase::SurvivingClaim).await; +} + +#[tokio::test] +async fn should_resolve_locked_competitor_using_persisted_chainlock_after_restart() { + assert_conflict_restart(ConflictCase::PersistedChainLock).await; +} + +#[tokio::test] +async fn should_not_rewrite_conflicting_history_during_recovery_load() { + assert_conflict_restart(ConflictCase::Recovery).await; +} + +#[tokio::test] +async fn should_preserve_a_recordless_winners_claim_during_replay() { + assert_conflict_restart(ConflictCase::RecordlessClaim).await; +} + +#[tokio::test] +async fn should_preserve_a_recordless_winners_placeholder_during_replay() { + assert_conflict_restart(ConflictCase::RecordlessPlaceholder).await; +} + +#[tokio::test] +async fn should_preserve_unknown_materialized_claim_during_startup_conflict_repair() { + for height in [None, Some(102)] { + assert_conflict_restart(ConflictCase::UnknownClaim { + placeholder: false, + height, + }) + .await; + } +} + +#[tokio::test] +async fn should_preserve_unknown_placeholder_claim_during_startup_conflict_repair() { + for height in [None, Some(102)] { + assert_conflict_restart(ConflictCase::UnknownClaim { + placeholder: true, + height, + }) + .await; + } +} + +#[tokio::test] +async fn should_reject_malformed_claimant_during_startup_conflict_repair() { + assert_conflict_restart(ConflictCase::MalformedClaim).await; +} + +#[tokio::test] +async fn should_roll_back_a_failed_replay_repair() { + assert_conflict_restart(ConflictCase::FailedRepair).await; +} + +#[tokio::test] +async fn should_not_let_a_defeated_lock_remove_a_surviving_spender() { + assert_conflict_restart(ConflictCase::DefeatedLock).await; +} + +#[derive(Clone, Copy)] +enum ConflictCase { + Released, + HeightOnlyFunding, + SurvivingClaim, + PersistedChainLock, + Recovery, + RecordlessClaim, + RecordlessPlaceholder, + UnknownClaim { + placeholder: bool, + height: Option, + }, + MalformedClaim, + FailedRepair, + Assets, + AssetsRecovery, + AssetsFailedRepair, + Unconverged, + DefeatedLock, +} + +async fn assert_conflict_restart(case: ConflictCase) { + let record_funding = !matches!(case, ConflictCase::HeightOnlyFunding); + let surviving_claim = matches!( + case, + ConflictCase::SurvivingClaim | ConflictCase::DefeatedLock + ); + let chainlocked = matches!( + case, + ConflictCase::PersistedChainLock | ConflictCase::DefeatedLock + ); + let recordless_claim = matches!( + case, + ConflictCase::RecordlessClaim | ConflictCase::RecordlessPlaceholder + ); + let unknown_claim = matches!(case, ConflictCase::UnknownClaim { .. }); + let expect_released = !surviving_claim && !recordless_claim && !unknown_claim; + let recovery = matches!(case, ConflictCase::Recovery | ConflictCase::AssetsRecovery); + let assets = matches!( + case, + ConflictCase::Assets | ConflictCase::AssetsRecovery | ConflictCase::AssetsFailedRepair + ); + + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([71; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .into_iter() + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + }; + let coins: Vec<_> = funding + .output + .iter() + .enumerate() + .map(|(vout, output)| { + Utxo::new( + OutPoint::new(funding.txid(), vout as u32), + output.clone(), + address.clone(), + 100, + false, + ) + }) + .collect(); + let loser = Transaction { + version: 1, + lock_time: 0, + input: coins + .iter() + .map(|coin| TxIn { + previous_output: coin.outpoint, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 119_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let winner = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: coins[0].outpoint, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + if !record_funding { + records.clear(); + } + let mut competing = info.clone(); + let mut other_spender = info.clone(); + records.extend( + info.check_core_transaction(&loser, TransactionContext::Mempool, &mut wallet, true, true) + .await + .new_records, + ); + records.extend( + competing + .check_core_transaction( + &winner, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + let mut surviving_txid = None; + if surviving_claim { + let mut extra_spender = winner.clone(); + extra_spender.input[0].previous_output = coins[1].outpoint; + extra_spender.output[0].value = 19_000; + surviving_txid = Some(extra_spender.txid()); + records.extend( + other_spender + .check_core_transaction( + &extra_spender, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + let mut locks = std::collections::BTreeMap::new(); + if chainlocked { + records + .iter_mut() + .find(|record| record.txid == winner.txid()) + .unwrap() + .context = block(101); + locks.insert( + loser.txid(), + InstantLock { + inputs: coins.iter().map(|coin| coin.outpoint).collect(), + txid: loser.txid(), + ..Default::default() + }, + ); + } else { + locks.insert( + winner.txid(), + InstantLock { + inputs: vec![coins[0].outpoint], + txid: winner.txid(), + ..Default::default() + }, + ); + } + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("released-input.sqlite"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records, + new_utxos: coins + .iter() + .cloned() + .chain([Utxo::new( + OutPoint::new(loser.txid(), 0), + loser.output[0].clone(), + address, + 0, + false, + )]) + .collect(), + spent_utxos: coins.clone(), + instant_locks_for_non_final_records: locks, + last_applied_chain_lock: chainlocked.then_some(ChainLock { + block_height: 101, + block_hash: BlockHash::from_byte_array([101; 32]), + signature: [0; 96].into(), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let extra_key: Vec = persister + .lock_conn_for_test() + .query_row( + "SELECT outpoint FROM core_utxos WHERE wallet_id = ?1 AND value = 20000", + [wallet.wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + // Release controls carry a known loser claim; an unknown durable claim must stay held. + if expect_released { + persister + .lock_conn_for_test() + .execute( + "UPDATE core_utxos SET spent_in_txid = ?1 WHERE wallet_id = ?2 AND outpoint = ?3", + rusqlite::params![ + loser.txid().as_byte_array().as_slice(), + wallet.wallet_id.as_slice(), + &extra_key + ], + ) + .unwrap(); + } + if let ConflictCase::UnknownClaim { height, .. } = case { + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET spent_in_txid = NULL, winner_mined_height = ?1 WHERE wallet_id = ?2 AND outpoint = ?3", + rusqlite::params![height, wallet.wallet_id.as_slice(), &extra_key], + ).unwrap(); + } + if matches!(case, ConflictCase::MalformedClaim) { + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET spent_in_txid = zeroblob(31) WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + ).unwrap(); + assert!(matches!( + typed_error(persister.load().unwrap_err()), + platform_wallet_storage::WalletStorageError::BlobDecode { .. } + )); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some()); + return; + } + let missing_winner = Txid::from_byte_array([72; 32]); + if recordless_claim { + let conn = persister.lock_conn_for_test(); + conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized) VALUES (?1, ?2, 101, 0)", + rusqlite::params![wallet.wallet_id.as_slice(), missing_winner.as_byte_array().as_slice()]).unwrap(); + conn.execute( + "UPDATE core_utxos SET spent_in_txid = ?1 WHERE wallet_id = ?2 AND value = 20000", + rusqlite::params![ + missing_winner.as_byte_array().as_slice(), + wallet.wallet_id.as_slice() + ], + ) + .unwrap(); + } + if matches!( + case, + ConflictCase::RecordlessPlaceholder + | ConflictCase::UnknownClaim { + placeholder: true, + .. + } + ) { + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key]).unwrap(); + } + let mut other_wallet_id = None; + if assets { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let entries: std::collections::BTreeMap<_, _> = + [AssetLockStatus::Broadcast, AssetLockStatus::Consumed] + .into_iter() + .enumerate() + .map(|(vout, status)| { + let out_point = OutPoint::new(loser.txid(), vout as u32); + ( + out_point, + AssetLockEntry { + out_point, + transaction: loser.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status, + proof: None, + }, + ) + }) + .collect(); + let other = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + other_wallet_id = Some(other.wallet_id); + let outpoint = OutPoint::new(loser.txid(), 0); + persister + .store( + other.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: other + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + asset_locks: Some(AssetLockChangeSet { + asset_locks: [(outpoint, entries[&outpoint].clone())] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: entries, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + } + if matches!( + case, + ConflictCase::FailedRepair | ConflictCase::AssetsFailedRepair + ) { + persister.lock_conn_for_test().execute_batch( + "CREATE TRIGGER fail_replay_repair BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent = 0 BEGIN SELECT RAISE(ABORT, 'injected replay repair failure'); END;", + ).unwrap(); + assert!(persister.load().is_err()); + if assets { + assert_eq!( + asset_count(&persister), + 3, + "failed reconciliation must roll back asset lifecycle deletions" + ); + } + assert!(persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some()); + let spent: bool = persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + |row| row.get(0), + ) + .unwrap(); + assert!(spent); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER fail_replay_repair") + .unwrap(); + } + if matches!(case, ConflictCase::Unconverged) { + persister.lock_conn_for_test().execute_batch("CREATE TRIGGER preserve_history BEFORE DELETE ON core_transactions BEGIN SELECT RAISE(IGNORE); END;").unwrap(); + let error = persister.load().unwrap_err(); + assert!( + matches!(typed_error(error), platform_wallet_storage::WalletStorageError::WalletRehydrationFailed { wallet_id, .. } if wallet_id == wallet.wallet_id) + ); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some()); + return; + } + drop(persister); + for _ in 0..2 { + let policy = if recovery { + platform_wallet_storage::LoadPolicy::Recovery + } else { + platform_wallet_storage::LoadPolicy::Strict + }; + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) + .unwrap(); + let mut state = persister.load().unwrap(); + if assets { + assert!( + state.wallets[&wallet.wallet_id] + .unused_asset_locks + .is_empty(), + "swept asset locks must not be resumable" + ); + assert_eq!( + asset_count(&persister), + if recovery { 3 } else { 2 }, + "consumed history is retained and Recovery rolls back removals" + ); + } + if let Some(other_id) = other_wallet_id { + assert!( + state.wallets[&other_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&OutPoint::new(loser.txid(), 0))), + "the same asset lock in another wallet must survive" + ); + } + let info = &state.wallets[&wallet.wallet_id].wallet_info; + let account = &info.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser.txid())); + if let Some(txid) = surviving_txid { + assert!(account.transactions().contains_key(&txid)); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &txid) + .unwrap() + .is_some()); + } + assert!( + !account.utxos.contains_key(&coins[0].outpoint), + "winner still spends the shared input" + ); + assert_eq!( + account.utxos.contains_key(&coins[1].outpoint), + expect_released, + "the extra input is free only when no surviving transaction claims it" + ); + assert_eq!( + info.balance.total(), + if expect_released { 20_000 } else { 0 } + ); + assert_eq!( + persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some(), + recovery + ); + if recordless_claim { + let claim: Vec = persister + .lock_conn_for_test() + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(claim, missing_winner.as_byte_array()); + } + let selection = CoinSelector::new(SelectionStrategy::LargestFirst).select_coins( + account.utxos.values(), + 5_000, + FeeRate::default(), + 100, + ); + if !expect_released { + assert!(selection.is_err()); + } else { + assert_eq!(selection.unwrap().selected[0].outpoint, coins[1].outpoint); + } + if recordless_claim || unknown_claim { + let loaded = state.wallets.get_mut(&wallet.wallet_id).unwrap(); + loaded + .wallet_info + .check_core_transaction(&funding, block(100), &mut loaded.wallet, true, true) + .await; + assert!( + !loaded.wallet_info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coins[1].outpoint), + "recordless claim must survive funding redelivery" + ); + assert_eq!(loaded.wallet_info.balance.total(), 0); + } + if let ConflictCase::UnknownClaim { + placeholder, + height, + } = case + { + let persisted: (bool, Option>, Option, bool) = persister.lock_conn_for_test().query_row( + "SELECT spent, spent_in_txid, winner_mined_height, is_sweep_placeholder FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet.wallet_id.as_slice(), &extra_key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ).expect("unknown claim must survive repair and reopen"); + assert_eq!( + persisted, + (true, None, height, placeholder), + "repair must retain nullable provenance through reload and funding redelivery" + ); + } + } +} + +fn asset_count(persister: &SqlitePersister) -> i64 { + persister + .lock_conn_for_test() + .query_row("SELECT COUNT(*) FROM asset_locks", [], |row| row.get(0)) + .unwrap() +} + +#[tokio::test] +async fn should_remove_swept_asset_locks_on_restart() { + assert_conflict_restart(ConflictCase::Assets).await; +} + +#[tokio::test] +async fn should_rollback_swept_asset_locks_in_recovery() { + assert_conflict_restart(ConflictCase::AssetsRecovery).await; +} + +#[tokio::test] +async fn should_rollback_swept_asset_locks_on_sql_failure() { + assert_conflict_restart(ConflictCase::AssetsFailedRepair).await; +} + +#[tokio::test] +async fn should_guard_recordless_spends_with_missing_funding_and_finality() { + for funding_state in ["absent", "height_only", "record"] { + for spender_row in [false, true] { + for placeholder in [false, true] { + for known_claimant in [false, true] { + let fixture = + Fixture::build(block(100), block(200), funding_state == "record").await; + { + let conn = fixture.persister.lock_conn_for_test(); + if spender_row { + conn.execute( + "UPDATE core_transactions SET record_blob = NULL WHERE txid != ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + ) + .unwrap(); + } else { + conn.execute( + "DELETE FROM core_transactions WHERE txid != ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + ) + .unwrap(); + } + if funding_state == "absent" { + conn.execute( + "DELETE FROM core_transactions WHERE txid = ?1", + [fixture.funding.txid().as_byte_array().as_slice()], + ) + .unwrap(); + } + if !known_claimant { + conn.execute( + "UPDATE core_utxos SET spent_in_txid = NULL WHERE spent = 1", + [], + ) + .unwrap(); + } + if placeholder { + conn.execute("UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE spent = 1", []).unwrap(); + } + } + let (mut wallet, mut info) = fixture.load(); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: [0; 96].into(), + }); + info.check_core_transaction( + &fixture.funding, + block(100), + &mut wallet, + true, + true, + ) + .await; + fixture.assert_spent_excluded(&info); + // A later loser must not release a different durable claimant's input. + let mut loser = fixture.funding.clone(); + loser.input = [fixture.spent, fixture.available] + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .collect(); + loser.output.truncate(1); + loser.output[0].value = 119_000; + info.check_core_transaction( + &loser, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + let mut winner = loser.clone(); + winner.input.remove(0); + winner.output[0] = TxOut { + value: 19_000, + script_pubkey: ScriptBuf::new(), + }; + info.check_core_transaction(&winner, block(301), &mut wallet, true, true) + .await; + info.check_core_transaction( + &fixture.funding, + block(100), + &mut wallet, + true, + true, + ) + .await; + assert!( + !info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fixture.spent), + "a later conflict cannot release a recordless durable claim" + ); + assert_eq!(info.balance.total(), 0); + } + } + } + } +} + +async fn assert_replay_amount_rejected(values: &[u64], received: u64) { + use key_wallet::managed_account::transaction_record::{InputDetail, OutputDetail, OutputRole}; + use platform_wallet_storage::{sqlite::schema::blob, WalletStorageError}; + let fixture = Fixture::with_height_only_funding(block(200)).await; + let conn = fixture.persister.lock_conn_for_test(); + let bytes: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE record_blob IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + let mut record: key_wallet::managed_account::transaction_record::TransactionRecord = + blob::decode(&bytes).unwrap(); + let address = record.input_details[0].address.clone(); + record.transaction.input = values + .iter() + .enumerate() + .map(|(index, _)| TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([90; 32]), index as u32), + ..Default::default() + }) + .collect(); + record.input_details = values + .iter() + .enumerate() + .map(|(index, value)| InputDetail { + index: index as u32, + value: *value, + address: address.clone(), + }) + .collect(); + record.transaction.output = vec![TxOut { + value: received, + script_pubkey: address.script_pubkey(), + }]; + record.output_details = vec![OutputDetail { + index: 0, + value: received, + address: Some(address), + role: OutputRole::Received, + }]; + record.net_amount = + i64::try_from(i128::from(received) - values.iter().map(|v| i128::from(*v)).sum::()) + .unwrap(); + record.txid = record.transaction.txid(); + conn.execute("DELETE FROM core_transactions", []).unwrap(); + conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, 200, 0, ?3)", rusqlite::params![fixture.wallet_id.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(&record).unwrap()]).unwrap(); + drop(conn); + let error = fixture + .persister + .load() + .expect_err("unsafe checker operands must return a typed error"); + assert!( + matches!( + typed_error(error), + WalletStorageError::IntegerOverflow { .. } + ), + "unsafe checker arithmetic must be rejected before replay" + ); +} + +#[tokio::test] +async fn should_reject_individually_overflowing_replay_input_with_fitting_net() { + assert_replay_amount_rejected(&[1_u64 << 63], 1).await; +} + +#[tokio::test] +async fn should_reject_cumulatively_overflowing_replay_inputs_with_fitting_net() { + assert_replay_amount_rejected(&[i64::MAX as u64, 1], 1).await; +} + +#[tokio::test] +async fn should_reject_overflowing_replay_output_with_fitting_net() { + assert_replay_amount_rejected(&[i64::MAX as u64], 1_u64 << 63).await; +} + +#[tokio::test] +async fn should_report_wallet_scoped_unconverged_replay() { + assert_conflict_restart(ConflictCase::Unconverged).await; +} + +fn typed_error( + error: platform_wallet::changeset::PersistenceError, +) -> platform_wallet_storage::WalletStorageError { + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("expected backend error") + }; + *source + .downcast::() + .unwrap() +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 1fe4d1eb514..9d8d8c92b81 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -35,6 +35,7 @@ use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::{AddressInfo, Network, PlatformP2PKHAddress, Utxo}; use crate::changeset::identity_scan_state::IdentityScanStateEntry; +use crate::changeset::wallet_accounting::apply_wallet_accounting; use crate::wallet::platform_wallet::WalletId; use dpp::balances::credits::Credits; @@ -423,9 +424,6 @@ impl HighestUsedIndexes { /// −0.005 stored for a −2.61920199 spend). /// /// The fold, per txid group of 2+ records: -/// - `net_amount` — the SUM of the slices: each account's -/// `received − spent` over disjoint detail sets, so the sum is the -/// wallet's `Σreceived − Σspent` by construction. /// - `input_details` / `output_details` — the union (deduped by input /// index / output index): the slices are disjoint per account, and the /// union is exactly the wallet-relevant view downstream consumers @@ -433,15 +431,7 @@ impl HighestUsedIndexes { /// - `fee` — the first `Some` (only the funding account's record carries /// one, and disjoint accounts cannot disagree); left `None` when no /// record knew it. -/// - `direction` — recomputed over the MERGED details with the same rule -/// upstream applies per account (`record_transaction`): `CoinJoin` -/// transaction type wins outright; otherwise no `Sent` output + our -/// inputs + our outputs → `Internal` (a cross-account move whose -/// account-local slices said `Outgoing`/`Incoming` is, wallet-level, a -/// self-transfer); otherwise our inputs → `Outgoing`, else `Incoming`. -/// Deriving from the net's sign instead erased `Internal` and -/// `CoinJoin`: an internal transfer nets −fee and would relabel -/// `Outgoing`. +/// - `net_amount` / `direction` — see the wallet-level pass below. /// - `context` — the most advanced in the group (`Mempool` < /// `InstantSend` < `InBlock` < `InChainLockedBlock`), so a group mixing /// a stale mempool observation with a confirmed one keeps the @@ -451,6 +441,22 @@ impl HighestUsedIndexes { /// input details) so the row's account attribution names the spender, /// else the first record. /// +/// Then EVERY record — folded or single — gets its wallet-level +/// `net_amount` and `direction` from its details via +/// [`apply_wallet_accounting`], the rule the SQLite repair also applies, +/// so a row never changes accounting when storage repairs it: +/// - `net_amount` is `Σ owned outputs − Σ owned inputs`. Over +/// detail-bearing slices that equals the sum of their nets, but a +/// keys-account marker's `+credit` (Platform credits, not Core funds) +/// stays out: an asset lock nets `−(credit + fee)`, not `−fee`. +/// - `direction` is [`wallet_direction`](super::wallet_direction): account-local slices that said +/// `Outgoing`/`Incoming` for a cross-account move become `Internal`, and +/// an asset lock with no change is `Internal` like one with change. +/// Deriving direction from the net's sign instead would erase +/// `Internal` and `CoinJoin`. +/// - A group or record with no details at all (keys markers only) keeps +/// upstream's net and direction. +/// /// Order-preserving for untouched records; a fold lands at the group's /// FIRST position (`group[0]`) regardless of which record supplied the /// funding metadata, so unrelated records between two slices never move @@ -458,8 +464,16 @@ impl HighestUsedIndexes { /// reach here (filtered at projection — see /// `core_bridge::is_contact_watch_only`). pub(crate) fn fold_same_txid_records(records: &mut Vec) { - use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; - use key_wallet::transaction_checking::transaction_router::TransactionType; + fold_groups(records); + for record in records.iter_mut() { + apply_wallet_accounting(record); + } +} + +/// The per-txid merge of [`fold_same_txid_records`], before the +/// wallet-level accounting pass. +fn fold_groups(records: &mut Vec) { + use key_wallet::managed_account::transaction_record::OutputRole; if records.len() < 2 { return; @@ -547,30 +561,10 @@ pub(crate) fn fold_same_txid_records(records: &mut Vec) { drop_idx.insert(base_pos); let first_pos = group[0]; drop_idx.remove(&first_pos); + // The slice sum stands only for a detail-less group (keys + // markers alone); `apply_wallet_accounting` recomputes net and + // direction from the merged details otherwise. merged.net_amount = net; - // Wallet-level direction over the merged details — same rule - // upstream applies per account (see the doc comment). The sign - // of the net cannot express `Internal` or `CoinJoin`. - merged.direction = if merged.transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else { - let has_inputs = !merged.input_details.is_empty(); - let has_sent = merged - .output_details - .iter() - .any(|d| d.role == OutputRole::Sent); - let has_our_outputs = merged - .output_details - .iter() - .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); - if !has_sent && has_inputs && has_our_outputs { - TransactionDirection::Internal - } else if has_inputs { - TransactionDirection::Outgoing - } else { - TransactionDirection::Incoming - } - }; folded.insert(first_pos, merged); } @@ -625,6 +619,33 @@ fn coalesce_newest_wins( } } +/// Keep one record per `(txid, account_type)` before folding account +/// contributions. The record with the higher [`context_rank`] wins +/// wholesale; on equal rank the later record wins. Records are never +/// spliced together: a record's amounts, inputs and outputs are only +/// coherent with the context they were observed under, so mixing a later +/// body with an earlier, higher-ranked context would fabricate a record +/// whose finality no single observation ever reported. +// TODO(unify-record-coalescing): `coalesce_newest_wins` (the `Merge` path) +// can regress a confirmed record to an older mempool slice; this helper +// keeps the higher-ranked record. Unify them once `Merge` semantics are +// reviewed. +pub(crate) fn coalesce_account_records(records: &mut Vec) { + let mut positions = BTreeMap::new(); + for record in std::mem::take(records) { + let key = (record.txid, record.account_type); + if let Some(&position) = positions.get(&key) { + let previous: &TransactionRecord = &records[position]; + if context_rank(&record.context) >= context_rank(&previous.context) { + records[position] = record; + } + } else { + positions.insert(key, records.len()); + records.push(record); + } + } +} + /// Rank a [`TransactionContext`](key_wallet::transaction_checking::TransactionContext) /// by how far along the confirmation lifecycle the observation is. /// Used by [`fold_same_txid_records`] so a fold never regresses a @@ -3599,3 +3620,72 @@ mod utxo_credit_verdict_merge_tests { assert!(older.utxo_credit_verdicts.is_empty()); } } + +#[cfg(test)] +mod coalesce_account_records_tests { + use super::*; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + + fn record(net_amount: i64, context: TransactionContext) -> TransactionRecord { + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + context, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + net_amount, + ); + record.txid = Txid::from_byte_array([7; 32]); + record + } + + fn in_block() -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new(100, BlockHash::all_zeros(), 0)) + } + + #[test] + fn should_keep_confirmed_record_intact_when_stale_mempool_record_follows() { + let mut records = vec![ + record(500, in_block()), + record(900, TransactionContext::Mempool), + ]; + coalesce_account_records(&mut records); + assert_eq!(records.len(), 1); + assert_eq!(records[0].net_amount, 500); + assert!(matches!(records[0].context, TransactionContext::InBlock(_))); + } + + #[test] + fn should_replace_with_later_record_of_equal_or_higher_rank() { + let mut equal = vec![ + record(500, TransactionContext::Mempool), + record(900, TransactionContext::Mempool), + ]; + coalesce_account_records(&mut equal); + assert_eq!(equal.len(), 1); + assert_eq!(equal[0].net_amount, 900); + + let mut higher = vec![ + record(500, TransactionContext::Mempool), + record(900, in_block()), + ]; + coalesce_account_records(&mut higher); + assert_eq!(higher.len(), 1); + assert_eq!(higher[0].net_amount, 900); + assert!(matches!(higher[0].context, TransactionContext::InBlock(_))); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 7d19ffe7372..bb1c2583170 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -59,8 +59,8 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ - merge_payment_overlays, AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PaymentOverlay, - PlatformWalletChangeSet, SweepBatch, UtxoCreditVerdict, + coalesce_account_records, merge_payment_overlays, AssetLockChangeSet, CoreChangeSet, + HighestUsedIndexes, PaymentOverlay, PlatformWalletChangeSet, SweepBatch, UtxoCreditVerdict, }; use crate::changeset::merge::Merge; use crate::changeset::persistence_capabilities::PersistenceCapabilities; @@ -1234,17 +1234,11 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. - for r in inserted { + // Corrections can discover owned inputs or outputs after the first observation. + for r in inserted.iter().chain(updated.iter()) { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - // Updated records (re-confirmation, IS-lock applied to a known - // mempool tx, etc.) don't usually change UTXO topology — the - // record's content does change though, so re-emit it. - // Matured coinbase records likewise: no UTXO topology change, - // just a status update for the persister. - // // Contact watch-only records are filtered out of all three // lists: re-emitting one on confirmation would re-clobber the // funding account's row with an incoming/positive @@ -1265,6 +1259,7 @@ async fn build_core_changeset( .filter(|r| !is_contact_watch_only(r)) .cloned(), ); + coalesce_account_records(&mut cs.account_records); cs.records = cs.account_records.clone(); crate::changeset::changeset::fold_same_txid_records(&mut cs.records); cs.last_processed_height = Some(*height); @@ -3438,6 +3433,40 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records.len(), 1); assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); + assert_eq!( + cs.spent_utxos.len(), + 1, + "updated records must project recognized inputs" + ); + assert_eq!( + cs.new_utxos.len(), + 1, + "updated records must project owned change" + ); + } + + #[tokio::test] + async fn should_coalesce_repeated_account_corrections_within_one_block() { + let (_, corrected, _) = contact_payment_records(); + let mut stale = corrected.clone(); + stale.input_details.clear(); + stale.net_amount = CHANGE as i64; + stale.direction = TransactionDirection::Incoming; + let event = WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_001, + chain_lock: None, + inserted: vec![stale], + updated: vec![corrected.clone()], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&test_manager(), &event).await; + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].net_amount, corrected.net_amount); + assert_eq!(cs.account_records.len(), 1); } /// A cross-account spend (CoinJoin-funded send with BIP44 change) @@ -3673,6 +3702,564 @@ mod contact_watch_only_projection_tests { ); } + /// Platform credits an asset lock moves into the wallet's own + /// keys account (`AssetLockPayload.credit_outputs`, mirrored by the + /// OP_RETURN output's value). + const LOCK_CREDIT: u64 = 60_000_000; + const LOCK_FEE: u64 = 1_000; + + /// The credit an asset lock spending [`our_input`] burns: everything + /// but the fee when there is no change, else [`LOCK_CREDIT`]. + fn lock_credit(change: bool) -> u64 { + if change { + LOCK_CREDIT + } else { + FUNDING - LOCK_FEE + } + } + + /// An asset lock spending [`our_input`]: output 0 burns + /// [`lock_credit`] into the OP_RETURN, output 1 (when `change`) is + /// our change, and the rest is the fee. + fn asset_lock_tx(change: bool) -> Transaction { + let mut tx = tx_with(&[]); + tx.version = 3; + tx.output.push(TxOut { + value: lock_credit(change), + script_pubkey: ScriptBuf::new_op_return(&[]), + }); + if change { + tx.output.push(TxOut { + value: FUNDING - LOCK_CREDIT - LOCK_FEE, + script_pubkey: our_change_address().script_pubkey(), + }); + } + tx + } + + /// The two per-account records upstream emits for an asset lock: + /// the funding account's slice (classified by upstream + /// `record_transaction`: `Unspendable` OP_RETURN, `Change` output, + /// `Outgoing` when nothing of ours comes back) and the keys + /// account's thin marker (`Internal`, no details, `+credit` net). + fn asset_lock_slices(change: bool) -> (TransactionRecord, TransactionRecord) { + let tx = asset_lock_tx(change); + let mut outputs = vec![OutputDetail { + index: 0, + role: OutputRole::Unspendable, + address: None, + value: lock_credit(change), + }]; + let (direction, change_value) = if change { + let value = FUNDING - LOCK_CREDIT - LOCK_FEE; + outputs.push(output(1, OutputRole::Change, &our_change_address(), value)); + (TransactionDirection::Internal, value) + } else { + (TransactionDirection::Outgoing, 0) + }; + let funding = record_with( + &tx, + bip44_account_0(), + in_block(1_000), + TransactionType::AssetLock, + direction, + vec![our_input()], + outputs, + change_value as i64 - FUNDING as i64, + ); + let keys = record_with( + &tx, + AccountType::AssetLockAddressTopUp, + in_block(1_000), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + lock_credit(change) as i64, + ); + (funding, keys) + } + + /// The wallet's Core balance drops by the locked credit plus the fee + /// — the credit leaves Core for the wallet's own Platform keys, so + /// the move is `Internal` and the net is `-(credit + fee)`, with or + /// without change. The keys account's `+credit` marker net counts + /// Platform credits, not Core funds, and must not be summed in (it + /// made the live row read `-fee` while every repair path wrote + /// `-(credit + fee)`). + #[tokio::test] + async fn should_project_asset_lock_as_internal_net_of_credit_and_fee() { + for change in [false, true] { + let (funding, keys) = asset_lock_slices(change); + let cs = + build_core_changeset(&test_manager(), &block_processed(vec![funding, keys])).await; + + assert_eq!(cs.records.len(), 1, "change={change}"); + let row = &cs.records[0]; + assert_eq!( + row.direction, + TransactionDirection::Internal, + "change={change}" + ); + assert_eq!( + row.net_amount, + -((lock_credit(change) + LOCK_FEE) as i64), + "change={change}" + ); + assert_eq!(row.account_type, bip44_account_0(), "change={change}"); + } + } + + /// A funding slice that never meets the keys marker (the keys + /// account did not match, or its slice lands in another round) is + /// projected with the same wallet rule — not left on upstream's + /// account-local `Outgoing`. + #[tokio::test] + async fn should_project_a_lone_asset_lock_funding_slice_as_internal() { + for change in [false, true] { + let (funding, _) = asset_lock_slices(change); + let cs = build_core_changeset(&test_manager(), &block_processed(vec![funding])).await; + + assert_eq!(cs.records.len(), 1, "change={change}"); + assert_eq!( + cs.records[0].direction, + TransactionDirection::Internal, + "change={change}" + ); + assert_eq!( + cs.records[0].net_amount, + -((lock_credit(change) + LOCK_FEE) as i64), + "change={change}" + ); + } + } + + /// The keys account's marker alone carries no Core accounting + /// evidence, so the projection leaves upstream's verdict alone. + #[tokio::test] + async fn should_keep_a_lone_keys_marker_as_upstream_emitted_it() { + let (_, keys) = asset_lock_slices(false); + let cs = build_core_changeset(&test_manager(), &block_processed(vec![keys])).await; + + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].direction, TransactionDirection::Internal); + assert_eq!(cs.records[0].net_amount, lock_credit(false) as i64); + } + + /// An asset lock that also pays someone else is a real outgoing + /// payment; the asset-lock exception covers only the burn. + #[tokio::test] + async fn should_project_asset_lock_paying_an_external_output_as_outgoing() { + let (mut funding, keys) = asset_lock_slices(true); + let paid = 5_000_000; + let mut tx = funding.transaction.clone(); + tx.output[1].value -= paid; + tx.output.push(TxOut { + value: paid, + script_pubkey: contact_address().script_pubkey(), + }); + funding.transaction = tx.clone(); + funding.txid = tx.txid(); + funding.output_details[1].value -= paid; + funding.net_amount -= paid as i64; + funding + .output_details + .push(output(2, OutputRole::Sent, &contact_address(), paid)); + let mut keys = keys; + keys.transaction = tx.clone(); + keys.txid = tx.txid(); + + let cs = build_core_changeset(&test_manager(), &block_processed(vec![funding, keys])).await; + + assert_eq!(cs.records.len(), 1); + assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); + assert_eq!( + cs.records[0].net_amount, + -((LOCK_CREDIT + LOCK_FEE + paid) as i64) + ); + } + + /// The wallet-level accounting pass runs on every projected record, + /// singles included. Outside asset locks it must reproduce what the + /// projection emitted before the pass existed: a single record kept + /// upstream's own `net_amount`/`direction`, and a group summed its + /// slices' nets and re-derived direction over the merged details. + /// Each case feeds upstream-shaped records (upstream's own net and + /// direction for its account slice) through the live projection and + /// pins that prior output (verified against the pre-pass fold). + /// + /// One intentional difference: a group made only of keys-account + /// markers (no details) used to re-derive `Incoming` from its empty + /// details. It now keeps upstream's `Internal`, like a single marker + /// always did and like the SQLite repair, which ignores detail-less + /// records. + #[tokio::test] + async fn should_keep_live_fold_accounting_for_non_asset_lock_transactions() { + const PAID: u64 = 60_000_000; + const FEE: u64 = 1_000; + let external = contact_address; + let second_account = || AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + let slice = |tx: &Transaction, + account: AccountType, + kind: TransactionType, + direction: TransactionDirection, + inputs: Vec, + outputs: Vec, + net: i64| { + record_with( + tx, + account, + in_block(1_000), + kind, + direction, + inputs, + outputs, + net, + ) + }; + use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; + use TransactionType::{ + AssetUnlock, CoinJoin as CoinJoinTx, Coinbase, ProviderRegistration, Standard, + }; + + let receive = tx_with(&[(&our_receive_address(), PAID)]); + let send_change = tx_with(&[(&external(), PAID), (&our_change_address(), CHANGE)]); + let send_all = tx_with(&[(&external(), FUNDING - FEE)]); + let self_send = tx_with(&[(&our_receive_address(), FUNDING - FEE)]); + let cross = tx_with(&[ + (&our_receive_address(), PAID), + (&our_change_address(), CHANGE), + ]); + let mix = tx_with(&[(&our_receive_address(), FUNDING - FEE)]); + let provider = tx_with(&[ + (&our_receive_address(), PAID), + (&our_change_address(), FUNDING - PAID - FEE), + ]); + let provider_ext = tx_with(&[ + (&external(), PAID), + (&our_change_address(), FUNDING - PAID - FEE), + ]); + let unlock = tx_with(&[(&our_receive_address(), PAID)]); + let mut coinbase = tx_with(&[(&our_receive_address(), PAID)]); + coinbase.lock_time = 9; + let keys_only = tx_with(&[]); + let (_, contact_funding, contact_watch_only) = contact_payment_records(); + let from_contact = tx_with(&[(&our_receive_address(), PAID)]); + + let cases: Vec<(&str, Vec, TransactionDirection, i64)> = vec![ + ( + "plain receive", + vec![slice( + &receive, + bip44_account_0(), + Standard, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "send with change", + vec![slice( + &send_change, + bip44_account_0(), + Standard, + Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &external(), PAID), + output(1, OutputRole::Change, &our_change_address(), CHANGE), + ], + CHANGE as i64 - FUNDING as i64, + )], + Outgoing, + CHANGE as i64 - FUNDING as i64, + ), + ( + "send without change", + vec![slice( + &send_all, + bip44_account_0(), + Standard, + Outgoing, + vec![our_input()], + vec![output(0, OutputRole::Sent, &external(), FUNDING - FEE)], + -(FUNDING as i64), + )], + Outgoing, + -(FUNDING as i64), + ), + ( + "self-transfer within one account", + vec![slice( + &self_send, + bip44_account_0(), + Standard, + Internal, + vec![our_input()], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + FUNDING - FEE, + )], + -(FEE as i64), + )], + Internal, + -(FEE as i64), + ), + ( + "self-transfer between own accounts", + vec![ + slice( + &cross, + bip44_account_0(), + Standard, + Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &our_receive_address(), PAID), + output(1, OutputRole::Change, &our_change_address(), CHANGE), + ], + CHANGE as i64 - FUNDING as i64, + ), + slice( + &cross, + second_account(), + Standard, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + ), + ], + Internal, + (PAID + CHANGE) as i64 - FUNDING as i64, + ), + ( + "coinjoin", + vec![slice( + &mix, + AccountType::CoinJoin { index: 0 }, + CoinJoinTx, + CoinJoin, + vec![our_input()], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + FUNDING - FEE, + )], + -(FEE as i64), + )], + CoinJoin, + -(FEE as i64), + ), + ( + "provider registration with owner-key marker", + vec![ + slice( + &provider, + bip44_account_0(), + ProviderRegistration, + Internal, + vec![our_input()], + vec![ + output(0, OutputRole::Received, &our_receive_address(), PAID), + output( + 1, + OutputRole::Change, + &our_change_address(), + FUNDING - PAID - FEE, + ), + ], + -(FEE as i64), + ), + slice( + &provider, + AccountType::ProviderOwnerKeys, + ProviderRegistration, + Internal, + vec![], + vec![], + 0, + ), + ], + Internal, + -(FEE as i64), + ), + ( + "provider registration paying external collateral", + vec![ + slice( + &provider_ext, + bip44_account_0(), + ProviderRegistration, + Outgoing, + vec![our_input()], + vec![ + output(0, OutputRole::Sent, &external(), PAID), + output( + 1, + OutputRole::Change, + &our_change_address(), + FUNDING - PAID - FEE, + ), + ], + -((PAID + FEE) as i64), + ), + slice( + &provider_ext, + AccountType::ProviderOwnerKeys, + ProviderRegistration, + Internal, + vec![], + vec![], + 0, + ), + ], + Outgoing, + -((PAID + FEE) as i64), + ), + ( + "asset unlock", + vec![slice( + &unlock, + bip44_account_0(), + AssetUnlock, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "coinbase", + vec![slice( + &coinbase, + bip44_account_0(), + Coinbase, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "keys-only single marker", + vec![slice( + &keys_only, + AccountType::IdentityRegistration, + TransactionType::AssetLock, + Internal, + vec![], + vec![], + PAID as i64, + )], + Internal, + PAID as i64, + ), + ( + "payment to a contact (watch-only slice present)", + vec![contact_funding, contact_watch_only], + Outgoing, + CHANGE as i64 - FUNDING as i64, + ), + ( + "payment from a contact into DashPay receiving funds", + vec![slice( + &from_contact, + AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [2u8; 32], + friend_identity_id: [1u8; 32], + }, + Standard, + Incoming, + vec![], + vec![output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID, + )], + PAID as i64, + )], + Incoming, + PAID as i64, + ), + ( + "keys-only group", + vec![ + slice( + &keys_only, + AccountType::IdentityRegistration, + TransactionType::AssetLock, + Internal, + vec![], + vec![], + PAID as i64, + ), + slice( + &keys_only, + AccountType::AssetLockAddressTopUp, + TransactionType::AssetLock, + Internal, + vec![], + vec![], + CHANGE as i64, + ), + ], + // Intentional change (see the doc comment): was `Incoming`. + Internal, + (PAID + CHANGE) as i64, + ), + ]; + let mut failures = Vec::new(); + for (name, records, direction, net) in cases { + let cs = build_core_changeset(&test_manager(), &block_processed(records)).await; + assert_eq!(cs.records.len(), 1, "{name}"); + let got = (cs.records[0].direction, cs.records[0].net_amount); + if got != (direction, net) { + failures.push(format!( + "{name}: expected {:?}, got {got:?}", + (direction, net) + )); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + /// A fold lands at the group's FIRST position even when the funding /// record (the metadata source) appears later — unrelated records /// between the slices must not move ahead of the folded transaction. diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 4125b8df568..3d81e6bb810 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -29,6 +29,7 @@ pub mod shielded_changeset_disabled; #[cfg(feature = "shielded")] pub mod shielded_sync_start_state; pub mod traits; +mod wallet_accounting; pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ @@ -60,3 +61,4 @@ pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncSta pub use traits::{ ListedCoreTxid, PersistenceError, PersistenceErrorKind, PlatformWalletPersistence, }; +pub use wallet_accounting::{is_owned, wallet_accounting, wallet_direction}; diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs new file mode 100644 index 00000000000..942f09d7589 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -0,0 +1,118 @@ +//! Wallet-level accounting of a Core transaction record: the ownership +//! predicate ([`is_owned`]), the direction rule ([`wallet_direction`]) and +//! the net formula ([`wallet_accounting`]). The live projection +//! (`fold_same_txid_records`) and the SQLite repair +//! (`platform-wallet-storage`'s `core_history`) both call them, so a row +//! cannot change accounting when storage repairs it; they differ only on +//! an `i64` overflow (live saturates, repair errors). The frozen V019 +//! migration keeps its own self-contained copy. +//! +//! Upstream `key-wallet` classifies each matched account on its own, and +//! its account-local views disagree for an asset lock: the funding +//! account reads a lock with no change as `Outgoing` (the OP_RETURN burn +//! is not an owned output), while the keys account holding the credit +//! keys reads it as `Internal`. From the wallet's side an asset lock +//! moves Core duffs into its own Platform credits, so only the fee +//! leaves the wallet. + +use std::collections::BTreeSet; + +use key_wallet::managed_account::transaction_record::{ + OutputRole, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::transaction_router::TransactionType; + +/// Classify a transaction from the wallet's point of view. +/// +/// - `spends_ours`: at least one input spends a wallet-owned output. +/// - `has_ours`: at least one output is wallet-owned (`Received`/`Change`). +/// - `has_external`: at least one output is neither wallet-owned nor an +/// OP_RETURN burn, so value leaves the wallet. +/// +/// An asset lock is `Internal` whenever nothing leaves the wallet, even +/// with no change output: its OP_RETURN value becomes the wallet's own +/// Platform credits. The Swift SDK's +/// `PersistentTransaction.reconciledAccounting` and the frozen V019 +/// migration apply the same rule; keep them in step. The case table +/// shared with Swift lives in `platform-wallet-storage` +/// (`should_classify_repaired_direction_like_the_swift_sdk`). +pub fn wallet_direction( + transaction_type: TransactionType, + spends_ours: bool, + has_ours: bool, + has_external: bool, +) -> TransactionDirection { + if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !spends_ours { + TransactionDirection::Incoming + } else if !has_external && (has_ours || transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + } +} + +/// Recompute a record's wallet-level `net_amount` and `direction` from +/// its input and output details, saturating a net that does not fit `i64`. +/// +/// A record with no details carries no accounting evidence (a keys-account +/// marker that met no funding slice), so it is left as upstream emitted +/// it. The SQLite repair skips such records for the same reason. +pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { + if record.input_details.is_empty() && record.output_details.is_empty() { + return; + } + let (net, direction) = wallet_accounting(record); + // Unreachable for real amounts (the whole supply fits i64 many times + // over); saturate rather than panic on a corrupt record. + record.net_amount = i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }); + record.direction = direction; +} + +/// Wallet-level net amount and direction of `record`, from its details. +/// +/// The net is `Σ owned outputs − Σ owned inputs`: the change in the +/// wallet's Core balance. A keys-account marker's `+credit` (Platform +/// credits, not Core funds) is therefore not part of it, so an asset lock +/// nets `−(credit + fee)`. The net is returned as `i128` so each caller +/// picks its own policy for a value outside `i64`. +pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirection) { + let owned: i128 = record + .output_details + .iter() + .filter(|d| is_owned(d.role)) + .map(|d| i128::from(d.value)) + .sum(); + let spent: i128 = record + .input_details + .iter() + .map(|d| i128::from(d.value)) + .sum(); + let has_ours = record.output_details.iter().any(|d| is_owned(d.role)); + // Indexed once so the per-output check below stays linear. + let accounted: BTreeSet = record + .output_details + .iter() + .filter(|d| is_owned(d.role) || d.role == OutputRole::Unspendable) + .map(|d| d.index as usize) + .collect(); + let has_external = record + .transaction + .output + .iter() + .enumerate() + .any(|(index, output)| !output.script_pubkey.is_op_return() && !accounted.contains(&index)); + let direction = wallet_direction( + record.transaction_type, + !record.input_details.is_empty(), + has_ours, + has_external, + ); + (owned - spent, direction) +} + +/// Whether an output with this role belongs to the wallet. +pub fn is_owned(role: OutputRole) -> bool { + matches!(role, OutputRole::Received | OutputRole::Change) +} diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index b9446432ee0..9e6661e5bab 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -18,6 +18,7 @@ use dashcore::Txid; use dashcore::{Network, Transaction}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::bip32::ExtendedPubKey; +use key_wallet::managed_account::transaction_record::TransactionRecord; // Only the `#[cfg(test)]` CoinJoin fixture needs the trait (for // `next_address_with_info` on a non-standard account); gate it to match so a // `test-utils`-only build does not flag it unused. @@ -32,6 +33,7 @@ use tokio::sync::RwLock; #[cfg(test)] use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use crate::changeset::changeset::fold_same_txid_records; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -173,6 +175,13 @@ impl ExtendedPubKeySigner for WalletSigner { } } +/// Runs the live projection's per-txid fold and wallet-level accounting +/// over upstream per-account records, so downstream crates can pin their +/// own accounting (the SQLite repair) against the live path. +pub fn fold_wallet_records(records: &mut Vec) { + fold_same_txid_records(records); +} + /// Builds a testnet wallet manager whose `account_type`/index-0 account /// holds a single spendable UTXO (10_000_000 duffs) — the whole balance /// rides on that one input, so a leaked reservation strands it. Returns @@ -283,9 +292,7 @@ pub(crate) fn observed_spend_event( tx: &Transaction, ) -> key_wallet_manager::WalletEvent { use dashcore::Address as DashAddress; - use key_wallet::managed_account::transaction_record::{ - InputDetail, TransactionDirection, TransactionRecord, - }; + use key_wallet::managed_account::transaction_record::{InputDetail, TransactionDirection}; use key_wallet::transaction_checking::transaction_router::TransactionType; let record = TransactionRecord::new( diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 2183bfbe76d..2e568c859da 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -5,7 +5,8 @@ distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the model checksum match the observed App Store 9.0.2 database. Active models are -V3. Other intermediate development shapes remain unsupported. +V4. The released V3 graph is preserved by `DashSchemaSnapshotV3`. Other +intermediate development shapes remain unsupported. The old V2 fixture was generated from September 8 sources containing 13 properties added on August 28, after the August 27 App Store release. The @@ -15,25 +16,28 @@ separate from archive-captured releases. This identifies a matching model source, not the confirmed build commit of Apple's binary. V2 is reserved: automated release capture must not register another shape under that number. -The main migration plan is historical V2 → V3. Accepted V1 has a separate -V1 → V3 plan: V1 already contains the 13 properties missing from historical -V2, so a V1 → V2 → V3 chain could discard values. Routing uses model metadata +The main migration plan is historical V2 → V3 → V4. Accepted V1 has a separate +V1 → V4 plan: V1 already contains the 13 properties missing from historical +V2, so a chain through V2 could discard values. Routing uses model metadata and runs after recovery; a version label alone never selects an unknown beta schema. The former live V2 is accepted only when its complete graph matches -current V3 exactly, entity hashes and checksum alike. That alias therefore -lasts only until the next live-graph change: after it, every store still -labelled `2.0.0` with the former live shape becomes `unsupported-v2` and fails -closed. Check internal devices still carrying that label before the next -shape change and migrate or deliberately reset them then, rather than -discovering them afterwards as failed opens. +frozen V3 exactly, entity hashes and checksum alike. The alias remains supported +when the live graph changes; other stores labelled `2.0.0` fail closed. + +V4 adds the optional `PersistentTransaction.netAmountUnavailable` marker through +a lightweight migration. Existing rows receive `nil`, preserving their stored +accounting. When deleting one participant of a shared transaction, the remaining +wallet's amount and direction are saved before the ownership links disappear. +If its amount was unresolved, the marker keeps it unavailable across restart; +an authoritative wallet record or complete accounting reconciliation clears it. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, -`accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +`accepted-v1-to-v4`, `historical-v2-to-v4`, `previous-live-v2-v3-shape`, +`published-v3-to-v4`, `unsupported-v2`, `labelled-current-v4`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a -label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the +label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` or `4.0.0` label takes the default plan without any probe, and a probe failure on the undecidable labels refuses the open with the probe's own error while leaving the store untouched. @@ -137,9 +141,9 @@ The run failed before upload, so this provenance establishes a tested source layout rather than proof of publication. Regression tests exercise the public factory, data/default preservation, writes, reopen, and failure recovery. -Keep the bridge for installations that skip the V3 app release. When advancing -to V4, bind `DashSchemaV3` to its released snapshot and retain the legacy-to-V3 -step before the normal V3-to-current plan. The bridge must never automatically +Keep the bridge for installations that skip the V3 app release. `DashSchemaV3` +stays bound to its released snapshot, retaining the legacy-to-V3 step before +the normal V3-to-current plan. The bridge must never automatically follow the latest live model graph. The release observer's one-time `bootstrap` only records its observation baseline; it neither runs this migration nor proves V1's App Store provenance. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift index 380deeeeff7..7224d3dc282 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift @@ -39,6 +39,9 @@ public struct DecodedTransaction: Sendable, Equatable { public let valueDuffs: UInt64 /// Raw scriptPubKey bytes. public let scriptPubkey: Data + + /// `true` for an `OP_RETURN` (0x6a) data-carrier / burn output. + var isOpReturn: Bool { scriptPubkey.first == 0x6a } } /// Transaction id in consensus (internal) byte order — reverse for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ea878415c36..22fbdb63ffc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -100,7 +100,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data. @@ -172,7 +172,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +185,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) return container } catch { logMigrationFailure(error) @@ -201,7 +201,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("3.0.0")]) + "target_version": .publicText("4.0.0")]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +218,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("3.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +236,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("3.0.0")]) + "route": .publicText(route), "target_version": .publicText("4.0.0")]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -247,7 +247,7 @@ public enum DashModelContainer { // probe stays fatal here: the open fails with the probe's own error // instead of an inapplicable plan, and the store is untouched. if versions == ["1.0.0"], try matches(DashSchemaV1.self) { - logRoute("accepted-v1-to-v3") + logRoute("accepted-v1-to-v4") return DashAcceptedV1MigrationPlan.self } if versions == ["2.0.0"] { @@ -261,12 +261,14 @@ public enum DashModelContainer { throw DashLegacyStoreSQLite.Failure.unsupported( "The database identifies itself as schema 2.0.0 but its model does not match the supported historical or current schema. The original database has not been replaced. Contact support; do not delete the app.") } - logRoute(historical ? "historical-v2-to-v3" : "previous-live-v2-current-shape") + logRoute(historical ? "historical-v2-to-v4" : "previous-live-v2-v3-shape") } else if versions == ["3.0.0"] { + logRoute("published-v3-to-v4") + } else if versions == ["4.0.0"] { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("labelled-current-v3") + logRoute("labelled-current-v4") } else { logRoute("ordinary-current-plan") } @@ -287,12 +289,13 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -300,9 +303,9 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV4.self] } static var stages: [MigrationStage] { - [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self)] + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV4.self)] } } @@ -372,9 +375,14 @@ public enum DashSchemaV2: VersionedSchema { } } -/// Current working schema. A later shape change must preserve this graph as -/// the fixed legacy-bridge target before introducing another live version. +/// Frozen released graph and fixed target of the legacy-store bridge. public enum DashSchemaV3: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } +} + +/// Live schema adds an optional accounting-availability marker; existing rows keep nil. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..a27fb766d11 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -84,8 +84,11 @@ public final class PersistentTransaction { /// replaces it with the real discriminant on touch. Accessors /// treat the sentinel as unknown (no branch fires). public var transactionTypeKind: UInt8 = 0xFF - /// Net amount in duffs (signed: positive=received, negative=sent). + /// Net Core amount in duffs across locally owned TXOs (positive=received, negative=sent). public var netAmount: Int64 + /// Set when removing a wallet left no authoritative amount for the survivor. + /// `nil` preserves the accounting of rows migrated from V3. + public var netAmountUnavailable: Bool? = nil /// Fee in duffs (nil if unknown). public var fee: UInt64? /// User-assigned label. @@ -228,12 +231,150 @@ public final class PersistentTransaction { } } + /// Core value movement for one wallet; `nil` when it cannot be derived yet. + /// + /// The stored scalar is the last recording wallet's (Rust `net_amount`, + /// or the reconciliation over its own TXOs), so it answers only when + /// `walletId` is the sole participant. Pending inputs do not veto that + /// answer: `upsertTransaction` writes one for every input whose prevout + /// has no local TXO — every foreign input of an incoming payment — and + /// never prunes them, so they cannot tell a late input of ours apart. + public func netAmount(for walletId: Data) -> Int64? { + func owned(_ rows: [PersistentTxo]) -> [PersistentTxo] { + var seen = Set() + return rows.filter { + PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) + && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + && seen.insert($0.outpoint).inserted + } + } + let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } + if participatingWalletIds == [walletId], !hasUnownedTxos { + return netAmountUnavailable == true ? nil : netAmount + } + // Computed from TXOs alone: a pending input this wallet recorded may be + // one of its own still-unlinked coins, so the sum is only provisional. + // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two + // local wallets leaves an unresolvable pending input per wallet, so both + // show "Amount unavailable". Store Rust's per-wallet net amount instead + // (tracked in #5226). + guard !pendingInputs.contains(where: { $0.walletId == walletId }) else { return nil } + let walletInputs = owned(inputs) + let walletOutputs = owned(outputs) + guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } + return Self.reconciledAccounting( + inputs: walletInputs, ownedOutputAmounts: walletOutputs.map(\.amount), + allOutputsOwned: false, previousDirection: direction, isAssetLock: isAssetLock + )?.netAmount + } + + /// Direction relative to one wallet for transactions shared by multiple local wallets. + public func direction(for walletId: Data) -> UInt32 { + guard participatingWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, + typedKind != .coinJoin else { return direction } + let spendsOurs = inputs.contains { + PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) + && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + } + if isAssetLock { + let ownedOutputs = outputs.filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + if spendsOurs { + return ownedOutputs.contains { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) != walletId + } ? CoreDirectionCode.outgoing : direction + } + return ownedOutputs.contains { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + } ? CoreDirectionCode.incoming : direction + } + return spendsOurs ? CoreDirectionCode.outgoing : CoreDirectionCode.incoming + } + + /// Retain the sole surviving wallet's accounting before ownership links disappear. + func preserveAccounting(removingWallet walletId: Data) { + let participants = participatingWalletIds + guard participants.count == 2, participants.contains(walletId), + let survivor = participants.first(where: { $0 != walletId }) else { return } + let amount = netAmount(for: survivor) + let survivorDirection = direction(for: survivor) + if let amount { netAmount = amount } + netAmountUnavailable = amount == nil + direction = survivorDirection + } + + /// Format the wallet's Core value movement in DASH. + public func formattedAmount(for walletId: Data) -> String { + guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } + return Self.format(duffs: amount) + } + + /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. + public func displayNetAmount(for walletId: Data?) -> Int64? { + walletId.map { netAmount(for: $0) } ?? (netAmountUnavailable == true ? nil : netAmount) + } + + /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. + public func displayDirectionCode(for walletId: Data?) -> UInt32 { + walletId.map { direction(for: $0) } ?? direction + } + + /// Formatted amount for `walletId`, or the stored scalar's when no wallet scope is given. + public func displayFormattedAmount(for walletId: Data?) -> String { + walletId.map { formattedAmount(for: $0) } ?? formattedAmount + } + + /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. + static func format(duffs: Int64) -> String { + String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) + } + + /// Local wallets owning one of this transaction's TXOs or having recorded it + /// (`involvedAccounts`), whose last writer's accounting is the stored scalar. + private var participatingWalletIds: Set { + let owning = (inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) } + let recording = involvedAccounts.compactMap { account -> Data? in + let wallet: PersistentWallet? = account.wallet + return wallet?.walletId + } + return Set(owning + recording) + } + + static func reconciledAccounting( + inputs: [PersistentTxo], ownedOutputAmounts: [UInt64], + allOutputsOwned: Bool, previousDirection: UInt32, isAssetLock: Bool + ) -> (netAmount: Int64, direction: UInt32)? { + func total(_ amounts: [UInt64]) -> Int64? { + var sum: Int64 = 0 + for amount in amounts { + guard let value = Int64(exactly: amount) else { return nil } + let addition = sum.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + sum = addition.partialValue + } + return sum + } + guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { + return nil + } + // Same rule as Rust (`platform_wallet::changeset::wallet_direction`): + // internal only when nothing leaves the wallet and something stays in + // it, or an asset lock burns into Platform. Both sides test one table. + let direction: UInt32 + if previousDirection == CoreDirectionCode.coinJoin { direction = CoreDirectionCode.coinJoin } + else if inputs.isEmpty { direction = CoreDirectionCode.incoming } + else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { + direction = CoreDirectionCode.internalTransfer + } else { direction = CoreDirectionCode.outgoing } + return (received - spent, direction) + } + public var directionName: String { switch direction { - case 0: return "Incoming" - case 1: return "Outgoing" - case 2: return "Internal" - case 3: return "CoinJoin" + case CoreDirectionCode.incoming: return "Incoming" + case CoreDirectionCode.outgoing: return "Outgoing" + case CoreDirectionCode.internalTransfer: return "Internal" + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Unknown" } } @@ -337,12 +478,19 @@ public final class PersistentTransaction { } public var formattedAmount: String { - let dash = Double(abs(netAmount)) / 100_000_000.0 - let sign = netAmount >= 0 ? "+" : "-" - return String(format: "%@%.8f DASH", sign, dash) + netAmountUnavailable == true ? "Amount unavailable" : Self.format(duffs: netAmount) } } +/// Wire values of `PersistentTransaction.direction`, matching `directionName` +/// and the FFI's `TransactionDirection` discriminants. +public enum CoreDirectionCode { + public static let incoming: UInt32 = 0 + public static let outgoing: UInt32 = 1 + public static let internalTransfer: UInt32 = 2 + public static let coinJoin: UInt32 = 3 +} + /// Typed mirror of Rust's /// `key_wallet::transaction_checking::transaction_router::TransactionType`, /// pinned to the `u8` discriminants emitted by diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d9ffdea6697..d7046d96cfe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -105,6 +105,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return wallet?.walletId } + /// Contact watch-only TXOs do not belong to the wallet tracking their addresses. + static func isWalletOwnedTxo(_ txo: PersistentTxo) -> Bool { + resolvedWalletId(of: txo) != nil + && txo.account?.accountType != dashpayExternalAccountTypeTag + && txo.coreAddress?.account?.accountType != dashpayExternalAccountTypeTag + } + + /// Network of the account's wallet, read through the same Optional cast + /// as `resolvedWalletId(of:)`. + static func walletNetwork(of account: PersistentAccount?) -> Network? { + let wallet: PersistentWallet? = account?.wallet + return wallet?.network + } + static func walletOwnsTransaction( walletId: Data, transaction: PersistentTransaction @@ -246,6 +260,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] } private var roundIndex: ChangesetRoundIndex? + private var accountingDirty: [Data: PersistentTransaction] = [:] /// Number of persistence rounds committed by this handler, read and /// compared on `serialQueue`. The store reconcile classifies rows off /// this queue and applies the verdicts on it; a round committed in @@ -411,6 +426,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { error: error ) backgroundContext.rollback() + // Rows staged by the failed save are gone; never reconcile them. + accountingDirty.removeAll() } } @@ -2538,7 +2555,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.hasBlockPosition = tx.has_block_position let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes - record.direction = tx.direction + // Empty asset-lock recovery records cannot replace a funded debit or an unavailable amount. + let preserveLockAccounting = tx.transaction_type_kind == TransactionTypeKind.assetLock.rawValue + && tx.net_amount == 0 && !tx.has_fee + && (record.netAmount < 0 || record.netAmountUnavailable == true) + if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) } @@ -2560,8 +2581,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.providerVotingKeyHash = tx.has_provider_voting_key_hash ? withUnsafeBytes(of: tx.provider_voting_key_hash) { Data($0) } : nil - record.netAmount = tx.net_amount - record.fee = tx.has_fee ? tx.fee : nil + if !preserveLockAccounting { + record.netAmount = tx.net_amount + record.netAmountUnavailable = false + record.fee = tx.has_fee ? tx.fee : nil + } + accountingDirty[record.txid] = record if let labelPtr = tx.label { record.label = String(cString: labelPtr) } @@ -2905,6 +2930,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// (`reconcileHealMissingTxos`), so both writers honour the same /// tombstone precedence and spender-adoption rules. private func drainPendingInputs(into record: PersistentTxo, resolvedWalletId: Data) { + if let parent = record.transaction { accountingDirty[parent.txid] = parent } + if let spender = record.spendingTransaction { accountingDirty[spender.txid] = spender } let pendingRows = pendingInputRows(outpoint: record.outpoint) if !pendingRows.isEmpty { // A tombstone is not an observation — it is a sweep's settled @@ -3037,7 +3064,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spender: PersistentTransaction, inputIndex: UInt32? ) { + accountingDirty[spender.txid] = spender let currentSpender = txo.spendingTransaction + if let currentSpender { accountingDirty[currentSpender.txid] = currentSpender } let verdict = Self.reconcileSpendObservation( currentSpenderTxid: currentSpender?.txid, currentSpenderContext: currentSpender?.context, @@ -3280,6 +3309,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() + // Out-of-round writers (heal paths, deferred backfills) stage + // entries too; they are not this round's to reconcile and may + // point at rows a failed save rolled back. Load-time accounting + // repairs whatever they touched. + self.accountingDirty.removeAll() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -3366,6 +3400,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() self.roundIndex = nil + self.accountingDirty.removeAll() self.roundAdvancedFinalityBoundary = false self.inChangeset = false self.drainDeferredBackfills() @@ -3408,6 +3443,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if roundAdvancedFinalityBoundary { collectFinalizedSweptTombstones(walletId: walletId) } + // Display-only accounting: a failure here must not fail the + // round, and is reported under its own event, not save_failed. + // Recomputed values that did land are consistent on their own. + // TODO(test-round-accounting-failure): cover this catch with a + // FetchFaultInjector test; pinning which read faults needs a Swift run. + do { + try reconcileTransactionAccounting(Array(accountingDirty.values)) + } catch { + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("round"), "wallet_reference": .reference(walletId)], + error: error + ) + } do { try backgroundContext.save() committedRoundGeneration &+= 1 @@ -6367,6 +6416,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if let walletRow = walletRow { + // Shared scalars must be scoped while the departing wallet's TXOs still exist. + for transaction in try backgroundContext.fetch(FetchDescriptor()) { + transaction.preserveAccounting(removingWallet: walletId) + } // Wallet → identities is `.nullify`; this delete // path cascades them explicitly. let identitiesToDelete = Array(walletRow.identities) @@ -6788,6 +6841,73 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return txids } + /// Repair only fully resolved spends; missing prevouts are not evidence of external ownership. + func reconcileTransactionAccounting( + _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil, + addresses: [String: PersistentCoreAddress]? = nil + ) throws { + for transaction in transactions where !transaction.isDeleted { + guard let transactionNetwork = network + ?? Self.walletNetwork(of: transaction.involvedAccounts.first) + ?? Self.walletNetwork(of: transaction.inputs.first?.account) + ?? Self.walletNetwork(of: transaction.outputs.first?.account), + let decoded = try? TransactionDecoder.decode( + transaction.transactionData, network: transactionNetwork + ), !decoded.inputs.isEmpty else { continue } + var inputs: [PersistentTxo] = [] + var complete = true + for input in decoded.inputs { + let key = PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) + let row: PersistentTxo? + if let txos { row = txos[key] } + else { row = try fetchTxoRowChecked(outpoint: key) } + guard let row, !row.isDeleted, Self.isWalletOwnedTxo(row) else { + complete = false + break + } + inputs.append(row) + } + guard complete else { continue } + var amounts: [UInt64] = [] + var allOutputsOwned = true + let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted && Self.isWalletOwnedTxo($0) }.map(\.vout)) + // An address-matched output with no linked TXO carries no wallet of + // its own, so it counts only for the spending wallets: another local + // wallet's credit must not hide inside the sender's scalar. + let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) + for (index, output) in decoded.outputs.enumerated() { + // OP_RETURN burns (including asset locks) are not spendable Core outputs. + if output.isOpReturn { continue } + var belongs = ownedVouts.contains(UInt32(index)) + if !belongs, let address = output.address { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) + let owner: PersistentCoreAddress? + if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } + else if let addresses { owner = addresses[address] } + else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { + let ownerWallet: PersistentWallet? = account.wallet + if let ownerWallet, spendingWallets.contains(ownerWallet.walletId) { + belongs = true + } + } + } + if belongs { amounts.append(output.valueDuffs) } + else { allOutputsOwned = false } + } + if let accounting = PersistentTransaction.reconciledAccounting( + inputs: inputs, ownedOutputAmounts: amounts, allOutputsOwned: allOutputsOwned, + previousDirection: transaction.typedKind == .coinJoin + ? CoreDirectionCode.coinJoin : transaction.direction, + isAssetLock: transaction.isAssetLock + ) { + transaction.netAmount = accounting.netAmount + transaction.netAmountUnavailable = false + transaction.direction = accounting.direction + } + } + } + /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { SDKLogger.event( @@ -6833,6 +6953,42 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } + // Mid-round the context holds another round's staged writes: saving + // would commit half of it and rolling back would silently drop it. + // That round reconciles its own dirty rows; the next load repairs the rest. + // TODO(persist-accounting-backfill-marker): this pass re-reads all + // history on every launch; a persisted completion marker needs a + // SwiftData shape change (a new live schema version) or a side + // channel, which is a product decision. + if !inChangeset { + do { + let walletIds = Set(wallets.map(\.walletId)) + let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + .filter { row in + walletIds.contains { Self.walletOwnsTransaction(walletId: $0, transaction: row) } + } + let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + // One read instead of one per unlinked output. + let addresses = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + try reconcileTransactionAccounting( + transactions, + // Never trap on a duplicate outpoint; a live row wins over a deleted one. + txos: Dictionary(txos.map { ($0.outpoint, $0) }, uniquingKeysWith: { kept, other in + kept.isDeleted ? other : kept + }), + addresses: Dictionary(addresses.map { ($0.address, $0) }, uniquingKeysWith: { first, _ in first }) + ) + try backgroundContext.save() + } catch { + // Display-only accounting must never block restoring wallets: + // drop this pass's edits and restore on the stored values. + backgroundContext.rollback() + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("load")], error: error + ) + } + } let restorable = wallets.filter { wallet in wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index c222a532b70..ba2092b1f68 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -3,36 +3,29 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction - /// Override amount for asset-lock txs. The wallet's `netAmount` - /// shows ~0 for these (credit output is structurally self-owned), - /// so the list view passes the linked - /// `PersistentAssetLock.amountDuffs`. `nil` for non-asset-lock - /// rows OR consumed asset locks whose tracking row was cleaned - /// up after successful identity registration. + var walletId: Data? = nil + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @State private var showCopiedAlert = false - /// Amount label rendered prominently at the top of the sheet. - /// Same precedence rule as the row: asset-lock duffs when we - /// have them, else an explicit "amount unknown" label for the - /// historical-asset-lock case (rather than the misleading - /// `+0.00000000 DASH` from `transaction.formattedAmount`). - /// `nil` for a payload-only provider special tx — a ProRegTx - /// observed via the owner/voting keys moves no wallet balance, - /// and `+0.00000000 DASH` reads as a broken zero-value receive. + /// Show the lock's funding amount, or the wallet's Core value movement for ordinary transactions. private var displayAmount: String? { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return nil } - return transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } private var typeDescription: String { @@ -42,11 +35,13 @@ struct TransactionDetailView: View { || transaction.isProviderSpecial { return transaction.displayDirection } - switch transaction.netAmount { - case let amount where amount > 0: + switch direction { + case CoreDirectionCode.incoming: return "Received" - case let amount where amount < 0: + case CoreDirectionCode.outgoing: return "Sent" + case CoreDirectionCode.coinJoin: + return "CoinJoin" default: return "Self-Transfer" } @@ -56,14 +51,7 @@ struct TransactionDetailView: View { if transaction.isAssetLock { return "lock.fill" } if transaction.isAssetUnlock { return "lock.open.fill" } if transaction.isProviderSpecial { return "server.rack" } - switch transaction.netAmount { - case let amount where amount > 0: - return "arrow.down.circle.fill" - case let amount where amount < 0: - return "arrow.up.circle.fill" - default: - return "arrow.triangle.2.circlepath" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -73,14 +61,7 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.netAmount { - case let amount where amount > 0: - return .green - case let amount where amount < 0: - return .red - default: - return .blue - } + return TransactionDirectionStyle.color(for: direction) } private var isConfirmed: Bool { @@ -208,7 +189,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, transaction.netAmount < 0 { + if let fee = formattedFee, let amount = netAmount, amount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift new file mode 100644 index 00000000000..589aa6bb7f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift @@ -0,0 +1,25 @@ +import SwiftUI +import SwiftDashSDK + +/// Direction icon and colour shared by the transaction list and detail views. +enum TransactionDirectionStyle { + static func icon(for direction: UInt32) -> String { + switch direction { + case CoreDirectionCode.incoming: return "arrow.down.circle.fill" + case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" + case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" + case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" + default: return "questionmark.circle" + } + } + + /// Internal transfers share the outgoing colour: they still pay a fee. + static func color(for direction: UInt32) -> Color { + switch direction { + case CoreDirectionCode.incoming: return .green + case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red + case CoreDirectionCode.coinJoin: return .blue + default: return .secondary + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index f185f78c20f..5d84aaca7e4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -29,10 +29,9 @@ struct TransactionListView: View { @Query private var walletAccounts: [PersistentAccount] @Query private var transactionObservation: [PersistentTransaction] /// Per-wallet asset-lock rows. Used to look up the *locked* amount - /// for each asset-lock tx — `PersistentTransaction.netAmount` is - /// the wallet's input-vs-output diff, which sees the credit - /// output as "to-self" and reports ~0 for asset locks. The - /// `amountDuffs` on the asset-lock row is the actual L1 burn. + /// for each asset-lock tx: `amountDuffs` on the asset-lock row is the + /// payload funding amount, while `PersistentTransaction.netAmount` is + /// the Core debit, which includes the fee. @Query private var assetLocks: [PersistentAssetLock] /// This wallet's owning identities. The DashPay payment / contact /// join below must be scoped to these — two identities in one store @@ -169,6 +168,7 @@ struct TransactionListView: View { .sheet(item: $selectedTransaction) { transaction in TransactionDetailView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmountByTxid[transaction.txidHex] ) } @@ -202,6 +202,7 @@ struct TransactionListView: View { } label: { TransactionRowView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmounts[transaction.txidHex], dashpayPayment: payment, dashpayCounterpartyName: payment.map { @@ -219,12 +220,12 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction - /// Override amount displayed for asset-lock rows. The wallet's - /// `netAmount` shows ~0 for these (credit output is structurally - /// self-owned), so the list view passes the linked - /// `PersistentAssetLock.amountDuffs` — the actual L1 DASH burned - /// to mint platform credits. `nil` for non-asset-lock rows or - /// when no matching row was found. + var walletId: Data? = nil + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in /// `TransactionListView`. When set, the row shows the contact context @@ -253,14 +254,7 @@ struct TransactionRowView: View { // `Internal` — the wallet just sees its own owner/voting/payout // keys in the payload — so the self-transfer arrows would lie. if transaction.isProviderSpecial { return "server.rack" } - // direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin - switch transaction.direction { - case 0: return "arrow.down.circle.fill" - case 1: return "arrow.up.circle.fill" - case 2: return "arrow.triangle.2.circlepath" - case 3: return "shuffle.circle.fill" - default: return "questionmark.circle" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -278,12 +272,7 @@ struct TransactionRowView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.direction { - case 0: return .green - case 1, 2: return .red - case 3: return .blue - default: return .secondary - } + return TransactionDirectionStyle.color(for: direction) } /// Primary label: the contact context for a DashPay payment, else the @@ -400,7 +389,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, transaction.netAmount < 0 { + if let fee = transaction.fee, let amount = netAmount, amount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) @@ -417,35 +406,23 @@ struct TransactionRowView: View { return String(format: "%.8f DASH", dash) } - /// Amount label for the row. For asset-lock txs we substitute - /// the linked `PersistentAssetLock.amountDuffs` (the L1 DASH - /// actually burned to mint platform credits); the wallet's - /// `netAmount` is ~0 for these because the credit output is a - /// self-owned address. Rendered as a negative (DASH leaving L1). - /// - /// If we know the row is an asset lock but the linked - /// `PersistentAssetLock` is missing (e.g. a historical record - /// from before the `Consumed`-status retention change shipped), - /// we render "Asset Lock (amount unknown)" instead of falling - /// through to `transaction.formattedAmount` — that would say - /// `+0.00000000 DASH`, which is misleading for a row the user - /// can see was a funding tx. + /// Keep asset-lock funding amounts distinct from the Core debit, which includes fees. private var displayAmount: String { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } // A payload-only provider special tx moves no wallet balance; // `+0.00000000 DASH` reads as a broken zero-value receive, so // put the tx kind in the amount slot instead. A provider tx // that DOES move value (e.g. this wallet funded the collateral) // falls through and shows the real signed amount. - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return transaction.providerSpecialName ?? transaction.transactionType } - return transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index b4627318cf0..ec38b7dae0f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1162,10 +1162,10 @@ struct TransactionStorageListView: View { // Direction. switch directionFilter { case .all: break - case .incoming where record.direction != 0: return false - case .outgoing where record.direction != 1: return false - case .internalTx where record.direction != 2: return false - case .coinjoin where record.direction != 3: return false + case .incoming where record.direction != CoreDirectionCode.incoming: return false + case .outgoing where record.direction != CoreDirectionCode.outgoing: return false + case .internalTx where record.direction != CoreDirectionCode.internalTransfer: return false + case .coinjoin where record.direction != CoreDirectionCode.coinJoin: return false default: break } // Type. Treat the legacy `"Standard"` placeholder (the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index b9aca49a5bc..1a77a6fcc30 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -347,8 +347,13 @@ final class AssetLockSpendVisibilityTests: XCTestCase { XCTAssertEqual( injector.observedReads, - ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], - "the wallet and unspent-TXO reads must have been served — only the lock read failed" + [ + // Wallet list, then the load-time accounting reconcile. + "PersistentWallet", "PersistentTransaction", "PersistentTxo", "PersistentCoreAddress", + // Unspent-TXO restore, then the faulted lock read. + "PersistentTxo", "PersistentAssetLock", + ], + "every read before the lock read must have been served — only the lock read failed" ) XCTAssertTrue( errored, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 982d9ab0afd..26a2fd54943 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,15 +11,18 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV4: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV5: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, BridgeFutureV4.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .custom(fromVersion: DashSchemaV3.self, toVersion: BridgeFutureV4.self, + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -770,7 +773,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV4.self) + let schema = Schema(versionedSchema: BridgeFutureV5.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( @@ -887,7 +890,9 @@ final class DashLegacySchemaMigrationTests: XCTestCase { let container = try ModelContainer(for: schema, configurations: [ ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) ]) - try verifyRows(container.mainContext) + let wallet = try XCTUnwrap(container.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(wallet.name, "historical audit wallet") } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) let container = try open(url, hooks: .init(visit: { _, _ in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1ac6248f4cc..a45c3e9321f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -75,11 +75,11 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let log = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(log.contains("event=store_open_started")) - XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) + XCTAssertTrue(log.contains("route=\"historical-v2-to-v4\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"3.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v3\"")) + XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV3RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["3.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index d283b5a20c0..7e268061af8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -99,6 +99,37 @@ final class DashReleasedSchemaTests: XCTestCase { } } + @MainActor + func testShouldMigrateV3AccountingAvailabilityAndPersistAnUnavailableAmount() throws { + let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { + $0.version.versionIdentifier == Schema.Version(3, 0, 0) + }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("accounting.store") + try FileManager.default.copyItem(at: source(fixture), to: url) + let txid = Data(repeating: 0x32, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertNil(row.netAmountUnavailable, "V3 rows retain their stored accounting by default") + XCTAssertEqual(row.displayNetAmount(for: nil), row.netAmount) + row.netAmountUnavailable = true + try container.mainContext.save() + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertEqual(row.netAmountUnavailable, true) + XCTAssertNil(row.displayNetAmount(for: nil)) + XCTAssertEqual(row.formattedAmount, "Amount unavailable") + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) + } + } + @MainActor func testRuntimePlanHasNoDuplicateModelChecksums() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift index 02b0bc78db8..f3f6d5deb40 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift @@ -12,7 +12,10 @@ final class PersistentTransactionDisplayTests: XCTestCase { /// Direction 2 = internal — the raw classification every special /// tx gets (asset locks, provider txs), which the display helpers /// exist to override. - private func makeTransaction(kind: UInt8, direction: UInt32 = 2) -> PersistentTransaction { + private func makeTransaction( + kind: UInt8, + direction: UInt32 = CoreDirectionCode.internalTransfer + ) -> PersistentTransaction { let tx = PersistentTransaction( txid: Data(repeating: 0xAB, count: 32), transactionData: Data(), @@ -53,7 +56,7 @@ final class PersistentTransactionDisplayTests: XCTestCase { XCTAssertEqual(makeTransaction(kind: 7).displayDirection, "Asset Unlock") // …and non-special kinds fall through to the raw direction. XCTAssertEqual(makeTransaction(kind: 0).displayDirection, "Internal") - XCTAssertEqual(makeTransaction(kind: 0, direction: 0).displayDirection, "Incoming") + XCTAssertEqual(makeTransaction(kind: 0, direction: CoreDirectionCode.incoming).displayDirection, "Incoming") XCTAssertEqual(makeTransaction(kind: 0xFF).displayDirection, "Internal") } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift new file mode 100644 index 00000000000..6fc27f08526 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -0,0 +1,695 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +@MainActor +final class TransactionAccountingTests: XCTestCase { + private func input(_ value: UInt64, wallet: UInt8 = 1) -> PersistentTxo { + let parent = PersistentTransaction(txid: Data(repeating: wallet, count: 32), transactionData: Data()) + let txo = PersistentTxo(transaction: parent, vout: 0, amount: value, address: "", height: 1) + txo.walletId = Data(repeating: wallet, count: 32) + return txo + } + + func testShouldRepairSpentInputsWithoutChangingBalanceOrConsumption() { + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let spent = input(100) + spent.isSpent = true + let result = PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [40], allOutputsOwned: false, previousDirection: 0, isAssetLock: false + ) + XCTAssertEqual(result?.netAmount, -60) + XCTAssertEqual(result?.direction, 1) + XCTAssertTrue(spent.isSpent) + XCTAssertEqual(tx.netAmount, 40) // Computing accounting has no storage side effects. + } + + func testShouldKeepInternalAndCoinJoinSemanticsWithNegativeNet() { + let spent = input(100) + XCTAssertEqual(PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: true, previousDirection: 0, isAssetLock: false + )?.direction, 2) + let lock = PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: true, previousDirection: 2, isAssetLock: true + ) + XCTAssertEqual(lock?.netAmount, -100) + XCTAssertEqual(lock?.direction, 2) + XCTAssertEqual(PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: false, previousDirection: 3, isAssetLock: false + )?.direction, 3) + } + + /// Same case table as the Rust repair's + /// `should_classify_repaired_direction_like_the_swift_sdk`. + func testShouldClassifyDirectionLikeTheRustRepair() { + let spent = input(100) + // (spends ours, owned output amounts, all outputs owned, asset lock, previous direction, expected) + let (incoming, outgoing, internalTransfer, coinJoin) = ( + CoreDirectionCode.incoming, CoreDirectionCode.outgoing, + CoreDirectionCode.internalTransfer, CoreDirectionCode.coinJoin + ) + let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ + (true, [99], true, false, incoming, internalTransfer), + (true, [40], false, false, incoming, outgoing), + (true, [], true, false, incoming, outgoing), + (true, [], true, true, incoming, internalTransfer), + (true, [40], true, true, incoming, internalTransfer), + (true, [], false, true, incoming, outgoing), + (false, [40], true, false, incoming, incoming), + (true, [99], true, false, coinJoin, coinJoin), + ] + for (index, (spendsOurs, owned, allOwned, isLock, previous, expected)) in cases.enumerated() { + let result = PersistentTransaction.reconciledAccounting( + inputs: spendsOurs ? [spent] : [], ownedOutputAmounts: owned, + allOutputsOwned: allOwned, previousDirection: previous, isAssetLock: isLock + ) + XCTAssertEqual(result?.direction, expected, "case \(index)") + } + } + + func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { + XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") + XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") + XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) + } + + func testShouldRejectOverflowInsteadOfWrappingHistory() { + XCTAssertNil(PersistentTransaction.reconciledAccounting( + inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false + )) + } + + func testShouldScopeNetToWalletAndDeduplicateOutpoints() { + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) + let first = input(100) + let other = input(200, wallet: 2) + tx.inputs = [first, first, other] + XCTAssertEqual(tx.netAmount(for: first.walletId), -100) + XCTAssertEqual(tx.netAmount(for: other.walletId), -200) + } + /// `burn` makes the single output an OP_RETURN, as an asset lock's is. + private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40, burn: Bool = false) -> Data { + var bytes = Data([2, 0, 0, 0, UInt8(inputs.count)]) + for txid in inputs { + bytes.append(txid) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255]) + } + bytes.append(1) + withUnsafeBytes(of: outputValue.littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: burn ? [1, 0x6a] : [0]) + bytes.append(contentsOf: [0, 0, 0, 0]) + return bytes + } + + func testShouldBackfillExistingHistoryOnLoadAndRemainIdempotent() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction(txid: walletId, transactionData: Data()) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) + coin.walletId = walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(funding) + context.insert(spender) + context.insert(coin) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + XCTAssertFalse(handler.loadWalletList().errored) + let fresh = ModelContext(container) + let repaired = try XCTUnwrap(fresh.fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(repaired.netAmount, -100) + XCTAssertEqual(repaired.direction, 1) + XCTAssertTrue(try XCTUnwrap(fresh.fetch(FetchDescriptor()).first).isSpent) + } + + func testShouldNotCommitOrDropOpenRoundWhenLoadRunsMidChangeset() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction(txid: walletId, transactionData: Data()) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) + coin.walletId = walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(funding) + context.insert(spender) + context.insert(coin) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let identityId = Data(repeating: 9, count: 32) + let fetchIdentity = { + try ModelContext(container).fetch(FetchDescriptor()).first { $0.identityId == identityId } + } + + handler.beginChangeset(walletId: walletId) + handler.persistIdentities( + walletId: walletId, + upserts: [.init( + identityId: identityId, balance: 100, revision: 1, identityIndex: 0, label: nil, + status: 0, walletId: walletId, dpnsNames: [], dashpayProfile: nil, contactProfiles: [] + )], + removed: [] + ) + XCTAssertFalse(handler.loadWalletList().errored) + XCTAssertNil(try fetchIdentity(), "load must not commit half of an open round") + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertNotNil(try fetchIdentity(), "load must not roll back an open round") + + // The skipped full pass runs on the next load outside any round. + XCTAssertFalse(handler.loadWalletList().errored) + let repaired = try ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == spender.txid } + XCTAssertEqual(repaired?.netAmount, -100) + } + + func testShouldRestoreWalletsWhenLoadTimeAccountingFails() throws { + let container = try DashModelContainer.createInMemory() + container.mainContext.insert(PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet)) + try container.mainContext.save() + let injector = FetchFaultInjector(faulting: PersistentCoreAddress.self) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet, modelFetcher: injector + ) + XCTAssertFalse(handler.loadWalletList().errored, "display accounting must not block restore") + XCTAssertTrue(injector.observedReads.contains("PersistentCoreAddress")) + } + + func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let coin = input(100) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), + transactionData: serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)]), + direction: CoreDirectionCode.outgoing, netAmount: -200 + ) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.first { $0.txid == spender.txid }?.netAmount, -200) + } + + private func persist( + _ handler: PlatformWalletPersistenceHandler, walletId: Data, txid: Data, + bytes: Data? = nil, net: Int64 = 0, kind: UInt8 = 0, + inputTxids: [Data] = [], outputs: [(Data, UInt64)] = [] + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("") + defer { free(name); free(address) } + var record = TransactionRecordFFI() + withUnsafeMutableBytes(of: &record.txid) { $0.copyBytes(from: txid) } + record.context = 2 + record.net_amount = net + record.transaction_type_kind = kind + var inputs = inputTxids.map { txid -> OutPointFFI in + var result = OutPointFFI() + withUnsafeMutableBytes(of: &result.txid) { $0.copyBytes(from: txid) } + return result + } + var txos = outputs.map { txid, amount -> UtxoEntryFFI in + var result = UtxoEntryFFI() + withUnsafeMutableBytes(of: &result.outpoint.txid) { $0.copyBytes(from: txid) } + result.amount = amount + result.address = address + return result + } + var raw = Array(bytes ?? Data()) + handler.beginChangeset(walletId: walletId) + raw.withUnsafeMutableBufferPointer { rawPtr in + inputs.withUnsafeMutableBufferPointer { inputPtr in + txos.withUnsafeMutableBufferPointer { txoPtr in + record.tx_data = rawPtr.baseAddress + record.tx_data_len = UInt(rawPtr.count) + record.input_outpoints = inputPtr.baseAddress + record.input_outpoints_count = UInt(inputPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = bytes == nil ? 0 : 1 + account.utxos_added = txoPtr.baseAddress + account.utxos_added_count = UInt(txoPtr.count) + withUnsafeMutablePointer(to: &account) { accountPtr in + var changeset = WalletChangeSetFFI() + changeset.accounts = accountPtr + changeset.accounts_count = 1 + withUnsafePointer(to: &changeset) { ptr in + XCTAssertTrue(handler.persistWalletChangeset(walletId: walletId, changeset: ptr)) + } + } + } + } + } + } + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + func testShouldRepairLateInputAndLateOutputInSeparateAtomicRounds() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let spenderId = Data(repeating: 3, count: 32) + persist(handler, walletId: walletId, txid: spenderId, + bytes: serializedSpend(inputs: [walletId]), net: 40, inputTxids: [walletId]) + persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) + persist(handler, walletId: walletId, txid: spenderId, outputs: [(spenderId, 40)]) + let context = ModelContext(container) + let row = try XCTUnwrap(context.fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -60) + XCTAssertEqual(row.direction, 2) + XCTAssertEqual(row.inputs.count, 1) + XCTAssertEqual(row.outputs.count, 1) + XCTAssertTrue(try XCTUnwrap(row.inputs.first).isSpent) + } + + func testShouldPreserveFundedAssetLockAccountingDuringSyntheticReplayWithMissingPrevout() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0, burn: true) + let spender = PersistentTransaction( + txid: spenderId, transactionData: bytes, + direction: CoreDirectionCode.internalTransfer, netAmount: -200 + ) + spender.transactionTypeKind = 6 + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6, inputTxids: [walletId]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200) + XCTAssertEqual(row.direction, 2) + XCTAssertEqual(row.context, 2) + } + + func testShouldPreserveAssetLockDebitWhenNoInputIsLinkedYet() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [Data(repeating: 2, count: 32)], outputValue: 0, burn: true) + let lock = PersistentTransaction( + txid: spenderId, transactionData: bytes, + direction: CoreDirectionCode.internalTransfer, netAmount: -200 + ) + lock.transactionTypeKind = 6 + lock.fee = 7 + context.insert(lock) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200, "a synthetic zero update must not erase the debit") + XCTAssertEqual(row.fee, 7) + XCTAssertEqual(row.direction, 2) + } + + func testShouldRepairNoChangeAssetLockToFullCoreDebit() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let spenderId = Data(repeating: 3, count: 32) + persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) + persist(handler, walletId: walletId, txid: spenderId, + bytes: serializedSpend(inputs: [walletId], outputValue: 0, burn: true), kind: 6, inputTxids: [walletId]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -100) + XCTAssertEqual(row.direction, 2) + XCTAssertTrue(row.isAssetLock) + } + + func testShouldReportSoleWalletsStoredAmountDespitePendingInputs() { + let walletId = Data(repeating: 1, count: 32) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let change = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + change.walletId = walletId + tx.outputs = [change] + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletId + )] + XCTAssertEqual(tx.netAmount(for: walletId), 40, "the sole wallet's stored amount is Rust's net_amount") + } + + func testShouldNotComputeSharedAmountWhileOwnInputsArePending() { + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let toA = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + toA.walletId = walletA + let toB = PersistentTxo(transaction: tx, vout: 1, amount: 60, address: "", height: 1) + toB.walletId = walletB + tx.outputs = [toA, toB] + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletA + )] + XCTAssertNil(tx.netAmount(for: walletA), "an unlinked input A recorded may be A's own coin") + XCTAssertEqual(tx.netAmount(for: walletB), 60) + } + + func testShouldKeepIncomingAmountWhenInputsAreForeign() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let paymentId = Data(repeating: 3, count: 32) + let foreign = Data(repeating: 2, count: 32) + persist(handler, walletId: walletId, txid: paymentId, + bytes: serializedSpend(inputs: [foreign]), net: 40, + inputTxids: [foreign], outputs: [(paymentId, 40)]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == paymentId }) + XCTAssertEqual(row.netAmount, 40) + XCTAssertEqual(row.netAmount(for: walletId), 40, "a foreign input is not an unresolved input of ours") + } + + private func localTransfer(in context: ModelContext, assetLock: Bool = false) throws -> PersistentTransaction { + let walletA = PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet) + let walletB = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let accountA = PersistentAccount(wallet: walletA, accountType: 0, accountIndex: 0, accountTypeName: "Standard") + let accountB = PersistentAccount(wallet: walletB, accountType: 0, accountIndex: 0, accountTypeName: "Standard") + context.insert(walletA) + context.insert(walletB) + context.insert(accountA) + context.insert(accountB) + let tx = PersistentTransaction( + txid: Data(repeating: 3, count: 32), + transactionData: serializedSpend(inputs: [walletA.walletId], outputValue: 90), + direction: CoreDirectionCode.internalTransfer, netAmount: -10 + ) + tx.transactionTypeKind = assetLock ? TransactionTypeKind.assetLock.rawValue : TransactionTypeKind.standard.rawValue + let coin = input(100) + coin.account = accountA + coin.isSpent = true + coin.spendingTransaction = tx + let credit = PersistentTxo(transaction: tx, vout: 0, amount: 90, address: "", height: 1) + credit.walletId = walletB.walletId + credit.account = accountB + context.insert(tx) + context.insert(coin) + context.insert(credit) + tx.involvedAccounts = [accountA, accountB] + try context.save() + return tx + } + + func testShouldKeepSurvivingWalletAccountingAfterDeletingEitherTransferWallet() throws { + for deletedWallet in [UInt8(1), UInt8(2)] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let survivor = Data(repeating: deletedWallet == 1 ? 2 : 1, count: 32) + let amount: Int64 = deletedWallet == 1 ? 90 : -100 + let direction = deletedWallet == 1 ? CoreDirectionCode.incoming : CoreDirectionCode.outgoing + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + _ = try localTransfer(in: container.mainContext) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + try handler.deleteWalletData(walletId: Data(repeating: deletedWallet, count: 32)) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertEqual(row.netAmount(for: survivor), amount) + XCTAssertEqual(row.direction(for: survivor), direction) + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertEqual(row.netAmount(for: survivor), amount) + XCTAssertEqual(row.direction(for: survivor), direction) + } + } + } + + func testShouldKeepUnavailableAmountAfterDeletingAnotherParticipant() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let survivor = Data(repeating: 2, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let foreign = Data(repeating: 9, count: 32) + tx.transactionData = serializedSpend(inputs: [Data(repeating: 1, count: 32), foreign], outputValue: 90) + tx.netAmount = 0 + let pending = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: foreign, vout: 0), inputIndex: 1, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: survivor + ) + container.mainContext.insert(pending) + try container.mainContext.save() + XCTAssertNil(tx.netAmount(for: survivor)) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: Data(repeating: 1, count: 32)) + // An empty asset-lock recovery update must not invent accounting. + persist(handler, walletId: survivor, txid: tx.txid, bytes: tx.transactionData, kind: 6) + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertNil(row.netAmount(for: survivor)) + XCTAssertEqual(row.formattedAmount(for: survivor), "Amount unavailable") + // A new authoritative wallet record can make the amount available. + persist(handler, walletId: survivor, txid: row.txid, bytes: row.transactionData, net: 90, kind: 6) + let updated = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == row.txid }) + XCTAssertEqual(updated.netAmount(for: survivor), 90) + } + } + + func testShouldScopeSharedAssetLockDirectionBeforeAndAfterReconciliation() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let walletA = Data(repeating: 1, count: 32) + let walletB = Data(repeating: 2, count: 32) + // A's Rust record is outgoing, while reconciliation sees both local wallets. + tx.direction = CoreDirectionCode.outgoing + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.direction, CoreDirectionCode.internalTransfer) + XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(row.direction(for: walletB), CoreDirectionCode.incoming) + } + + func testShouldKeepOwnAssetLockConversionInternalWithAnotherKeysOnlyParticipant() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let walletA = Data(repeating: 1, count: 32) + let change = try XCTUnwrap(tx.outputs.first) + change.walletId = walletA + change.account = tx.inputs.first?.account + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) + XCTAssertEqual(tx.direction(for: Data(repeating: 2, count: 32)), CoreDirectionCode.internalTransfer) + // A no-change conversion remains internal too. + container.mainContext.delete(change) + tx.transactionData = serializedSpend(inputs: [walletA], outputValue: 90, burn: true) + try container.mainContext.save() + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.internalTransfer) + } + + func testShouldScopeDirectionAndAmountToEachWalletOfALocalTransfer() { + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + let (amount, fee): (UInt64, UInt64) = (90, 10) + let coin = input(amount + fee, wallet: 1) + // Whichever wallet recorded last owns the stored scalars; here the sender. + let tx = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: Data(), + direction: CoreDirectionCode.outgoing, netAmount: -Int64(amount + fee) + ) + let credit = PersistentTxo(transaction: tx, vout: 0, amount: amount, address: "", height: 1) + credit.walletId = walletB + tx.inputs = [coin] + tx.outputs = [credit] + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) + XCTAssertEqual(tx.netAmount(for: walletA), -Int64(amount + fee)) + XCTAssertEqual(tx.netAmount(for: walletB), Int64(amount)) + XCTAssertEqual(tx.formattedAmount(for: walletB), "+0.00000090 DASH") + } + + func testShouldReportAmountUnavailableOnlyWhenNoWalletAccountingApplies() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + func makeAccount(of walletId: Data) -> PersistentAccount { + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let account = PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + context.insert(wallet) + context.insert(account) + return account + } + let (accountA, accountB) = (makeAccount(of: walletA), makeAccount(of: walletB)) + // Payload-only (no linked TXO), e.g. a provider tx matched by key. + let payloadOnly = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) + let shared = PersistentTransaction(txid: Data(repeating: 4, count: 32), transactionData: Data(), netAmount: 40) + let unrecorded = PersistentTransaction(txid: Data(repeating: 5, count: 32), transactionData: Data(), netAmount: 40) + context.insert(payloadOnly) + context.insert(shared) + context.insert(unrecorded) + payloadOnly.involvedAccounts = [accountA] + shared.involvedAccounts = [accountA, accountB] + try context.save() + + XCTAssertEqual(payloadOnly.netAmount(for: walletA), 0, "the sole recorder's stored amount stands") + XCTAssertEqual(payloadOnly.formattedAmount(for: walletA), "+0.00000000 DASH") + XCTAssertNil(shared.netAmount(for: walletA), "the stored scalar may be the other recorder's") + XCTAssertNil(shared.netAmount(for: walletB)) + XCTAssertEqual(shared.formattedAmount(for: walletA), "Amount unavailable") + XCTAssertNil(unrecorded.netAmount(for: walletA), "a wallet with no stake has no amount") + } + + func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let senderId = Data(repeating: 1, count: 32) + let receiver = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let receiverAccount = PersistentAccount( + wallet: receiver, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + // P2PKH to pubkey hash 0x05 x 20 on testnet: B's address with no TXO row yet. + let receiverAddress = PersistentCoreAddress( + address: "yLmzEvw3frCPS4cyRmFFeKbt64fUPzMwFh", poolTypeTag: 0, addressIndex: 0, derivationPath: "" + ) + receiverAddress.account = receiverAccount + context.insert(PersistentWallet(walletId: senderId, network: .testnet)) + context.insert(receiver) + context.insert(receiverAccount) + context.insert(receiverAddress) + var bytes = Data([2, 0, 0, 0, 1]) + bytes.append(senderId) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) + withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: [25, 0x76, 0xa9, 0x14] + [UInt8](repeating: 5, count: 20) + [0x88, 0xac]) + bytes.append(contentsOf: [0, 0, 0, 0]) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: CoreDirectionCode.outgoing, netAmount: -100 + ) + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100, "the receiving wallet's credit is not the sender's") + XCTAssertEqual(row.netAmount(for: senderId), -100) + } + + func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let coin = input(100) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.internalTransfer, netAmount: -60 + ) + coin.spendingTransaction = spender + let contactOutput = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) + contactOutput.walletId = walletId + contactOutput.account = contactAccount + context.insert(coin) + context.insert(spender) + context.insert(contactOutput) + try context.save() + XCTAssertEqual(spender.netAmount(for: walletId), -100) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100) + XCTAssertEqual(row.direction, 1) + XCTAssertEqual(row.netAmount(for: walletId), -100) + } + + func testShouldNotTreatPersistedContactInputAsOurFunding() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let coin = input(100) + coin.account = contactAccount + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + coin.spendingTransaction = spender + let received = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) + received.walletId = walletId + context.insert(coin) + context.insert(spender) + context.insert(received) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, 40) + XCTAssertEqual(row.direction, 0) + XCTAssertEqual(row.netAmount(for: walletId), 40) + } + +}