Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b3904ee
fix(wallet): use upstream late transaction accounting corrections
lklimek Sep 28, 2026
a72737a
fix(swift-sdk): reconcile persisted Core transaction accounting
lklimek Sep 28, 2026
c4a3be4
fix(swift-sdk): exclude contact TXOs from history accounting
lklimek Sep 28, 2026
60e1f6d
fix(wallet): reconcile persisted Core transaction accounting
lklimek Sep 28, 2026
82cec94
refactor(wallet): pin minimal Core transaction accounting fix
lklimek Sep 28, 2026
85deb41
test(drive): track latest protocol version in grovedb structure snapshot
PastaPastaPasta Sep 28, 2026
37ea1bb
Merge branch 'v4.3-dev' into fix/pr-5126
lklimek Sep 29, 2026
e80b55e
Merge remote-tracking branch 'origin/v4.3-dev' into fix/pr-5126
lklimek Sep 29, 2026
c0425f7
fix(platform-wallet-storage): restore confirmed spends before wallet …
lklimek Sep 29, 2026
9c8dff2
fix(platform-wallet-storage): keep each replayed UTXO in one funds ac…
lklimek Sep 29, 2026
4b80513
fix(platform-wallet-storage): never load contact-only outputs as spen…
lklimek Sep 29, 2026
3d04bb0
fix(swift-sdk): skip load-time accounting repair inside an open chang…
lklimek Sep 29, 2026
55730ff
Merge remote-tracking branch 'origin/v4.3-dev' into fix/pr-5126
lklimek Sep 30, 2026
9766169
fix(platform-version): select folded DRIVE_ABCI_QUERY_VERSIONS_V2 in …
lklimek Sep 30, 2026
28796ac
fix(platform-wallet): drop unused IdentityGettersV0 import in dpns
lklimek Sep 30, 2026
c2a5765
test(drive): regenerate GroveDB structure for protocol version 15
lklimek Sep 30, 2026
c4a5b23
fix(platform-wallet-storage)!: harden wallet history restore after #5…
lklimek Sep 30, 2026
1279864
fix(platform-wallet-storage): gate V019 blob length before reading it…
lklimek Sep 30, 2026
b4b7366
fix(platform-wallet): let the higher-ranked record win account coales…
lklimek Sep 30, 2026
fcfa464
test(swift-sdk): expect the load-time accounting reads before the loc…
lklimek Sep 30, 2026
b004bc9
fix(swift-sdk): keep incoming amounts when a payment has foreign inputs
lklimek Sep 30, 2026
1c8ec82
test(swift-sdk): cover wallet-scoped direction and amounts
lklimek Sep 30, 2026
a70ee60
fix(platform-wallet-storage): validate birth height before scheduling…
lklimek Sep 30, 2026
c0f3a4c
fix(platform-wallet): classify asset locks as internal on the live path
lklimek Sep 30, 2026
6e82bf1
test(platform-wallet): pin live-fold accounting for non-asset-lock tr…
lklimek Sep 30, 2026
7002582
docs(swift-sdk): point the direction rule comment at wallet_direction
lklimek Sep 30, 2026
a1f46c0
Merge branch 'v5.1-dev' into fix/structure-tests-latest-protocol
PastaPastaPasta Oct 1, 2026
cbed68d
test(drive): regenerate grovedb structure snapshot after v5.0-dev merge
PastaPastaPasta Oct 1, 2026
5e74f42
Merge branch 'v5.1-dev' into fix/pr-5126
lklimek Oct 1, 2026
b1610d3
fix(platform-wallet-storage): ignore witness-only body differences fo…
lklimek Oct 1, 2026
a4e1c3f
refactor(platform-wallet): share the net-amount formula with the SQLi…
lklimek Oct 1, 2026
be7f8ec
fix(swift-sdk): read account wallets through the Optional cast in acc…
lklimek Oct 1, 2026
628efc3
test(drive): relabel team-action layers in grovedb-structure.json for…
lklimek Oct 1, 2026
f78bccb
perf(platform-wallet): index accounted outputs once in wallet accounting
lklimek Oct 1, 2026
3eff6f6
Merge branch 'v5.1-dev' into fix/pr-5126
lklimek Oct 2, 2026
aec5bb8
Merge remote-tracking branch 'origin/fix/structure-tests-latest-proto…
lklimek Oct 2, 2026
bfbaf31
Merge branch 'v5.1-dev' into fix/structure-tests-latest-protocol
lklimek Oct 2, 2026
b042573
Merge remote-tracking branch 'origin/fix/structure-tests-latest-proto…
lklimek Oct 2, 2026
defafa2
fix(platform-wallet-storage): rebuild spend marks for height-only fun…
lklimek Oct 2, 2026
0f09a3a
fix(platform-wallet-storage): settle InstantSend conflicts regardless…
lklimek Oct 2, 2026
bd49cfe
fix(platform-wallet-storage): gate owned-output script length before …
lklimek Oct 2, 2026
d82b65e
docs(swift-sdk): mark shared-wallet amount gap for Rust-sourced accou…
lklimek Oct 2, 2026
999a9ae
Merge branch 'v5.1-dev' into fix/structure-tests-latest-protocol
lklimek Oct 2, 2026
754e7fa
Merge remote-tracking branch 'origin/fix/structure-tests-latest-proto…
lklimek Oct 5, 2026
4e632ce
fix(platform-wallet-storage): sweep confirmed conflicts after history…
lklimek Oct 5, 2026
97179c6
fix(platform-wallet-storage): reconcile settled history across accounts
lklimek Oct 5, 2026
8d6d9e7
fix(swift-sdk): preserve wallet accounting when participants change
lklimek Oct 5, 2026
9894a0c
fix(wallet-storage): preserve durable claims during replay recovery
lklimek Oct 5, 2026
9cd322f
Merge remote-tracking branch 'origin/v5.1-dev' into fix/pr-5126
lklimek Oct 5, 2026
a2b6f50
fix(wallet-storage): retain unknown claims during startup repair
lklimek Oct 5, 2026
f2e572b
test(wallet-storage): verify clean event-driven spend restoration
lklimek Oct 6, 2026
5ee6f20
chore(platform)!: merge rust-dashcore migration and pin spent-claim API
lklimek Oct 6, 2026
d397edb
chore: stack wallet accounting repairs on the v5.1 rust-dashcore port
lklimek Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions packages/rs-platform-wallet-storage/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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. |
Original file line number Diff line number Diff line change
@@ -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()
}
48 changes: 48 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -418,6 +422,38 @@ pub enum WalletStorageError {
blob_height: Option<u32>,
},

/// 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.
Expand Down Expand Up @@ -754,6 +790,9 @@ impl WalletStorageError {
| Self::AssetLockEntryMismatch { .. }
| Self::AssetLockStatusMismatch { .. }
| Self::CoreTransactionEntryMismatch { .. }
| Self::TransactionBodyConflict { .. }
| Self::UnknownWalletNetwork { .. }
| Self::NetAmountOverflow { .. }
| Self::BlobTooLarge { .. }
| Self::IntegerOverflow { .. }
| Self::RehydrationPoolMismatch { .. }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -858,6 +902,7 @@ impl WalletStorageError {
| Self::AssetLockEntryMismatch { .. }
| Self::AssetLockStatusMismatch { .. }
| Self::CoreTransactionEntryMismatch { .. }
| Self::UnknownWalletNetwork { .. }
| Self::BlobTooLarge { .. }
| Self::IntegerOverflow { .. }
| Self::RehydrationPoolMismatch { .. }
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)?;
Expand All @@ -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<'_> {
Expand All @@ -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;
}
Expand Down Expand Up @@ -420,6 +425,16 @@ pub fn embedded_migrations_sql() -> Vec<String> {
.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::*;
Expand Down
Loading
Loading