Skip to content

Commit ca1612e

Browse files
authored
fix(platform-wallet-storage): durably apply swept transactions in the SQLite store (#4559)
2 parents 86b7244 + 27fbd47 commit ca1612e

6 files changed

Lines changed: 4518 additions & 43 deletions

File tree

packages/rs-platform-wallet-storage/SCHEMA.md

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,11 @@ erDiagram
9090
BLOB outpoint PK "bincode-encoded OutPoint"
9191
INTEGER value "satoshis"
9292
BLOB script "scriptPubKey bytes"
93-
INTEGER height "NULL if unconfirmed"
93+
INTEGER height "funding height, 0 if unconfirmed; NULL only for an unmaterialised sweep placeholder"
9494
INTEGER account_index
9595
INTEGER spent "0 | 1"
96-
BLOB spent_in_txid "NULL until spend; cleared by trigger on tx delete"
96+
BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL"
97+
INTEGER winner_mined_height "V007: sweep winner's mined height; NULL when unstamped or materialised"
9798
}
9899
99100
CORE_INSTANT_LOCKS {
@@ -115,6 +116,7 @@ erDiagram
115116
BLOB wallet_id PK "one row per wallet"
116117
INTEGER last_processed_height "NULL until first block processed"
117118
INTEGER synced_height "NULL until first sync"
119+
INTEGER chainlock_height "V007: monotonic-max applied chainlock height; NULL until one is applied"
118120
}
119121
```
120122

@@ -381,14 +383,44 @@ is `1` once block context is present.
381383

382384
### `core_utxos`
383385

384-
One row per UTXO, spent or unspent. `spent_in_txid` is set to NULL
385-
by a trigger when its referenced `core_transactions` row is deleted
386-
(instead of a native `ON DELETE SET NULL`, which would also null the
387-
NOT NULL `wallet_id` column).
386+
One row per UTXO, spent or unspent. `spent_in_txid` is written only by
387+
`apply_sweep`, naming the winner that took an input a swept loser claimed
388+
but this store had no released record for. It is set to NULL by a trigger
389+
when its referenced `core_transactions` row is deleted (instead of a native
390+
`ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id`
391+
column) — and by a later sweep that releases the same outpoint.
392+
393+
What gates the funding UTXO's own later upsert (`execute_upsert_utxo`) is
394+
the row's shape, not that link: a never-materialised held row (`height`
395+
NULL, `spent = 1` — the placeholder `apply_sweep` writes for an input whose
396+
funding this store had not seen) stays spent when the funding arrives, with
397+
or without a `spent_in_txid` (the trigger can null it underneath a live
398+
hold). A materialised row follows the wallet: it knows the coin, any
399+
network-final spender of a coin it knows is wallet-relevant, so its view of
400+
`spent` is authoritative and a re-delivery clears both `spent` and the
401+
link. A delivery through `spent_utxos` onto a placeholder materialises it
402+
the same way instead of marking it in place.
403+
404+
`winner_mined_height` (V007) stamps that claim with the mined height of the
405+
winner named in `spent_in_txid`, and decides the placeholder's lifetime
406+
rather than its existence. A block-context sweep stamps the winner's own
407+
height and `collect_finalized_tombstones` evicts the row once
408+
`min(chainlock_height, synced_height)` reaches it — upstream's
409+
`prune_finalized_observed_spends` boundary verbatim. An InstantSend-locked
410+
winner that is not yet mined leaves it NULL: the lock alone settles the
411+
input, but it carries no height to key a lifetime on, so the row resolves
412+
only through proof (the funding upsert materialising it, a later
413+
block-context sweep re-stamping it, or a release). The funding upsert
414+
clears the stamp, because a materialised row is the wallet's own coin held
415+
spent and is permanently outside the collector's reach.
388416

389417
- PK: `(wallet_id, outpoint)`.
390418
- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
391419
- Index: `idx_core_utxos_spent(wallet_id, spent)`.
420+
- Index: `idx_core_utxos_unmaterialized(wallet_id, winner_mined_height)
421+
WHERE height IS NULL` (V007) — covers exactly the unmaterialised rows, so
422+
the collector's per-round scan touches tombstones rather than the
423+
wallet's full spent history.
392424

393425
### `core_instant_locks`
394426

@@ -413,6 +445,13 @@ One row per wallet, holding monotonically-advancing SPV sync watermarks.
413445
`last_processed_height` and `synced_height` are NULL until the first
414446
block is processed.
415447

448+
`chainlock_height` (V007) mirrors `CoreChangeSet::last_applied_chain_lock`
449+
as a monotonic max — the height alone, which this store previously dropped.
450+
It is one half of the finality boundary
451+
`collect_finalized_tombstones` collects sweep tombstones against, so a
452+
tombstone is never collected before a chainlock has been persisted,
453+
matching upstream's "no-op until a chainlock has been applied".
454+
416455
- PK: `wallet_id` (single-row-per-wallet).
417456
- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
418457

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//! Anchor sweep tombstones at their winner's mined height and pin the
2+
//! chainlock finality boundary.
3+
//!
4+
//! `core_utxos.winner_mined_height` is the mined height of the
5+
//! transaction that beat an unmaterialised sweep tombstone's outpoint —
6+
//! the placeholder row `apply_sweep` writes for a held input whose
7+
//! funding output has never classified (`height IS NULL AND spent = 1`;
8+
//! no other writer leaves `height` NULL). The height is carried on the
9+
//! sweep event itself (`TransactionsSwept::winner_mined_height`), and
10+
//! `apply_sweep` writes the placeholder for EVERY non-released held
11+
//! input in EVERY sweep context — only the stamp differs. A
12+
//! block-context sweep (winner actually mined) stamps the winner's
13+
//! height, and the collector in `core_state::apply` evicts the row
14+
//! exactly when `min(chainlock_height, synced_height)` reaches that
15+
//! height — `prune_finalized_observed_spends`' condition verbatim, with
16+
//! no observation-age margin. An InstantSend-locked, unmined winner
17+
//! writes the same row with the stamp NULL — under DIP-10 its lock
18+
//! alone settles the input, but it carries no height to key a lifetime
19+
//! on — and the collector never takes an unstamped row: it resolves
20+
//! only through proof, when the funding upsert materialises it, a later
21+
//! block-context sweep re-stamps it into the collectible set, or a
22+
//! release deletes it. See `CORE_SWEEP_REMOVAL` and the `apply_sweep`
23+
//! doc in `core_state.rs` for why an unstamped hold must survive (it is
24+
//! the only durable carrier of upstream's in-memory `spent_outpoints`
25+
//! hold across a restart) and what bounds the foreign-input residue.
26+
//!
27+
//! `core_sync_state.chainlock_height` is the monotonic-max height of
28+
//! the last applied chainlock, mirrored from
29+
//! `CoreChangeSet::last_applied_chain_lock` (previously dropped by
30+
//! this store). It is one half of the collector's finality boundary;
31+
//! rows are never collected before a chainlock has been persisted,
32+
//! matching upstream's "no-op until a chainlock has been applied".
33+
//!
34+
//! The partial index covers exactly the unmaterialised rows — the
35+
//! collector's scan set is the stamped subset of these — so the
36+
//! per-round sweep touches tombstones only, not the wallet's full
37+
//! spent history.
38+
//!
39+
//! Version numbers, like capability bits, are append-only: an already-
40+
//! merged assignment keeps its slot, so an unmerged migration takes the
41+
//! next free number whenever the mainline lands one ahead of it. Under
42+
//! the pre-release policy V001's test documents, an unshipped migration
43+
//! may still be edited in place; a dev database that applied an earlier
44+
//! shape fails refinery's divergence check and must be recreated.
45+
46+
pub fn migration() -> String {
47+
"ALTER TABLE core_utxos ADD COLUMN winner_mined_height INTEGER;
48+
ALTER TABLE core_sync_state ADD COLUMN chainlock_height INTEGER;
49+
CREATE INDEX idx_core_utxos_unmaterialized
50+
ON core_utxos(wallet_id, winner_mined_height)
51+
WHERE height IS NULL;"
52+
.to_string()
53+
}

packages/rs-platform-wallet-storage/src/sqlite/persister.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,11 @@ impl PlatformWalletPersistence for SqlitePersister {
829829
// Do NOT attest WALLET_RESTORE (and therefore not provider restore):
830830
// `load()` still reports `ClientStartState::wallets` in
831831
// `LOAD_UNIMPLEMENTED`. Shielded state lives in a separate store.
832+
// `CORE_SWEEP_REMOVAL`: the full contract — loser removal, the
833+
// outpoint-keyed placeholder for a held input whose funding has not
834+
// classified, releases by outpoint, and the finality-boundary
835+
// collector — is implemented and documented in `core_state::apply`,
836+
// `apply_sweep` and `collect_finalized_tombstones`.
832837
PersistenceCapabilities::ATOMIC_CHANGESETS
833838
.union(PersistenceCapabilities::INVITATIONS)
834839
.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES)
@@ -837,6 +842,8 @@ impl PlatformWalletPersistence for SqlitePersister {
837842
.union(PersistenceCapabilities::DPNS_NAME_STATES)
838843
.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS)
839844
.union(PersistenceCapabilities::TRACKED_MASTERNODES)
845+
.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL)
846+
.union(PersistenceCapabilities::DASHPAY_PAYMENTS)
840847
}
841848

842849
fn persist_tracked_masternodes(

packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,21 @@ pub fn apply(
7373
if !cs.removed.is_empty() {
7474
// Same terminal rule as the upsert guard: a stored `consumed`
7575
// row is never deleted by a stale tombstone. Consumed rows are
76-
// deliberately retained for historical lookup, and the only
77-
// removal emitter (`untrack_asset_lock`) fires exclusively for
78-
// Built rows whose broadcast was rejected — so a removal
79-
// reaching a consumed row is by construction a stale write.
76+
// deliberately retained for historical lookup, and neither
77+
// removal producer can legitimately name one — a Built row
78+
// rejected at broadcast (`untrack_asset_lock`) never got that
79+
// far, and the sweep-driven removal that arrives with the
80+
// producer only tombstones entries still tracked, which a
81+
// consumed lock no longer is — so a removal reaching a consumed
82+
// row is by construction a stale write.
83+
//
84+
// Ordering note, scoped honestly: this applies upserts before
85+
// removals. On this branch that is layout rather than a
86+
// guarantee — the fold-level cancellation that would make an
87+
// upsert and a tombstone for one outpoint impossible lands with
88+
// the producer, and even there it does not cover every status.
89+
// The `status != 'consumed'` predicate is what this statement
90+
// actually relies on, and it holds regardless of the fold.
8091
let mut stmt = tx.prepare_cached(
8192
"DELETE FROM asset_locks \
8293
WHERE wallet_id = ?1 AND outpoint = ?2 AND status != 'consumed'",

0 commit comments

Comments
 (0)