Skip to content

Commit 46e63c8

Browse files
docs(platform-wallet-storage): document V007 and pin the materialised release
Two review follow-ups. `SCHEMA.md` described `spent_in_txid` but not the three objects V007 creates, so the reference no longer matched the database: `core_utxos.winner_mined_height`, `core_sync_state.chainlock_height`, and the partial `idx_core_utxos_unmaterialized` covering exactly the unmaterialised rows. All three are now in the diagrams and the prose, including what the stamp decides (a placeholder's lifetime, never its existence) and why the funding upsert clears it. The second was raised as a missing release path for a materialised claim. The path exists — `apply` splits on `height IS NULL`, deleting an unmaterialised placeholder outright and freeing a materialised row in place — but nothing pinned that half: every other release test exercises the placeholder, so a release that silently skipped materialised rows would have left a live coin spent forever with nothing else able to free it, the collector being deliberately unable to take such a row. `a_release_frees_a_materialised_claim_in_place` closes that: seed a stamped tombstone, materialise it through the funding upsert, then have the winner itself swept with the coin released, and assert the row comes back unspent in place — keeping its funding data — and stays so across a restart.
1 parent 7498ffa commit 46e63c8

2 files changed

Lines changed: 116 additions & 0 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ erDiagram
9494
INTEGER account_index
9595
INTEGER spent "0 | 1"
9696
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

@@ -392,9 +394,26 @@ referenced `core_transactions` row is deleted (instead of a native
392394
`ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id`
393395
column) — and by a later sweep that releases the same outpoint.
394396

397+
`winner_mined_height` (V007) stamps that claim with the mined height of the
398+
winner named in `spent_in_txid`, and decides the placeholder's lifetime
399+
rather than its existence. A block-context sweep stamps the winner's own
400+
height and `collect_finalized_tombstones` evicts the row once
401+
`min(chainlock_height, synced_height)` reaches it — upstream's
402+
`prune_finalized_observed_spends` boundary verbatim. An InstantSend-locked
403+
winner that is not yet mined leaves it NULL: the lock alone settles the
404+
input, but it carries no height to key a lifetime on, so the row resolves
405+
only through proof (the funding upsert materialising it, a later
406+
block-context sweep re-stamping it, or a release). The funding upsert
407+
clears the stamp, because a materialised row is the wallet's own coin held
408+
spent and is permanently outside the collector's reach.
409+
395410
- PK: `(wallet_id, outpoint)`.
396411
- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
397412
- Index: `idx_core_utxos_spent(wallet_id, spent)`.
413+
- Index: `idx_core_utxos_unmaterialized(wallet_id, winner_mined_height)
414+
WHERE height IS NULL` (V007) — covers exactly the unmaterialised rows, so
415+
the collector's per-round scan touches tombstones rather than the
416+
wallet's full spent history.
398417

399418
### `core_instant_locks`
400419

@@ -419,6 +438,13 @@ One row per wallet, holding monotonically-advancing SPV sync watermarks.
419438
`last_processed_height` and `synced_height` are NULL until the first
420439
block is processed.
421440

441+
`chainlock_height` (V007) mirrors `CoreChangeSet::last_applied_chain_lock`
442+
as a monotonic max — the height alone, which this store previously dropped.
443+
It is one half of the finality boundary
444+
`collect_finalized_tombstones` collects sweep tombstones against, so a
445+
tombstone is never collected before a chainlock has been persisted,
446+
matching upstream's "no-op until a chainlock has been applied".
447+
422448
- PK: `wallet_id` (single-row-per-wallet).
423449
- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`.
424450

packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2962,6 +2962,96 @@ fn a_materialised_claim_is_never_collected() {
29622962
);
29632963
}
29642964

2965+
/// A MATERIALISED claim is releasable, and release is the only thing that
2966+
/// frees it.
2967+
///
2968+
/// The collector deliberately never takes such a row
2969+
/// (`a_materialised_claim_is_never_collected`): once the funding output has
2970+
/// classified, the row carries real funding data and is the wallet's own coin
2971+
/// held spent, so no finality boundary may reclaim it. That leaves exactly one
2972+
/// way back — a later sweep naming the outpoint in `released_outpoints`, which
2973+
/// the unmaterialised path handles by DELETE and this one by an in-place
2974+
/// `spent = 0, spent_in_txid = NULL`.
2975+
///
2976+
/// Pinned because the two paths diverge on `height IS NULL` and every other
2977+
/// release test exercises the placeholder half; without this one, a release
2978+
/// that silently skipped materialised rows would leave a live coin spent
2979+
/// forever with nothing else able to free it.
2980+
#[test]
2981+
fn a_release_frees_a_materialised_claim_in_place() {
2982+
let (persister, _tmp, path) = fresh_persister();
2983+
let w: WalletId = wid(0xF7);
2984+
ensure_wallet_meta(&persister, &w);
2985+
2986+
let addr = p2pkh(0x62);
2987+
let funding_txid = Txid::from_byte_array([0x71; 32]);
2988+
let p = OutPoint::new(funding_txid, 0);
2989+
let loser = Txid::from_byte_array([0x72; 32]);
2990+
let winner = Txid::from_byte_array([0x73; 32]);
2991+
let final_winner = Txid::from_byte_array([0x74; 32]);
2992+
2993+
let mut conn = persister.lock_conn_for_test();
2994+
derive_address(&conn, &w, 0, &addr);
2995+
apply_heights(&mut conn, &w, 100);
2996+
seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT));
2997+
2998+
// The funding output classifies: real data, stamp cleared, still spent.
2999+
{
3000+
let tx = conn.transaction().unwrap();
3001+
let cs = CoreChangeSet {
3002+
new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)],
3003+
..Default::default()
3004+
};
3005+
core_state::apply(&tx, &w, &cs).unwrap();
3006+
tx.commit().unwrap();
3007+
}
3008+
assert_eq!(
3009+
utxo_row_state(&conn, &w, &p),
3010+
Some((true, Some(10), None)),
3011+
"sanity: materialised — real height, stamp cleared, still spent"
3012+
);
3013+
assert!(
3014+
!unspent(&conn, &w).contains(&p),
3015+
"sanity: a held coin is not spendable"
3016+
);
3017+
3018+
// The winner is itself swept, and this time the coin comes back free.
3019+
{
3020+
let tx = conn.transaction().unwrap();
3021+
let cs = CoreChangeSet {
3022+
sweeps: vec![SweepBatch {
3023+
txids: vec![winner],
3024+
superseded_by: final_winner,
3025+
winner_mined_height: Some(WINNER_HEIGHT),
3026+
released_outpoints: vec![p],
3027+
}],
3028+
..Default::default()
3029+
};
3030+
core_state::apply(&tx, &w, &cs).unwrap();
3031+
tx.commit().unwrap();
3032+
}
3033+
3034+
assert_eq!(
3035+
utxo_row_state(&conn, &w, &p),
3036+
Some((false, Some(10), None)),
3037+
"a released materialised claim is freed in place, keeping its funding data"
3038+
);
3039+
assert!(
3040+
unspent(&conn, &w).contains(&p),
3041+
"and the coin is spendable again"
3042+
);
3043+
3044+
// Durable: the in-place release is not a memory-only flip.
3045+
drop(conn);
3046+
drop(persister);
3047+
let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap();
3048+
let conn = persister.lock_conn_for_test();
3049+
assert!(
3050+
unspent(&conn, &w).contains(&p),
3051+
"the release must hold across a restart"
3052+
);
3053+
}
3054+
29653055
/// A held, unmaterialised row with a NULL winner height is never
29663056
/// collected. The mempool-context sweep path writes exactly this shape
29673057
/// (an IS-locked, unmined winner has no finality horizon to stamp), and

0 commit comments

Comments
 (0)