Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
55 changes: 46 additions & 9 deletions packages/rs-platform-wallet-storage/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ erDiagram

ACCOUNT_REGISTRATIONS {
BLOB wallet_id PK
TEXT account_type PK "standard | coinjoin | identity_registration | ..."
TEXT account_type PK "standard_bip44 | standard_bip32 | coinjoin | identity_registration | ..."
INTEGER account_index PK
BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry"
}
Expand Down Expand Up @@ -105,9 +105,10 @@ erDiagram
CORE_DERIVED_ADDRESSES {
BLOB wallet_id PK
TEXT account_type PK
TEXT address PK "bech32 / Base58 address string"
INTEGER account_index
TEXT derivation_path "pool_type/derivation_index"
INTEGER account_index PK "owning account; also the value the read returns"
TEXT pool_type PK "external | internal | absent | absent_hardened"
INTEGER derivation_index PK
TEXT address UK "bech32 / Base58 address string"
INTEGER used "0 | 1"
}

Expand Down Expand Up @@ -403,12 +404,47 @@ finalized. Rows are removed when the transaction becomes confirmed.

### `core_derived_addresses`

Address-to-account-index map. Written before UTXOs in the same
transaction so the UTXO writer can resolve `account_index` by address.

- PK: `(wallet_id, account_type, address)`.
A live-fed indexed read-cache the UTXO writer joins to resolve a UTXO's
`account_index` by address. The authoritative manifest is
`account_address_pools` (kept complete and in-band by the
`core_bridge` emitter); this table is the fast B-tree probe in front of it.
Fed by exactly one source:

- **Live `addresses_derived` events** — written before UTXOs in the same
transaction so the writer sees fresh rows.

UTXO resolution for an unspent UTXO:

1. **Cache hit** — resolve from this table.
2. **Cache miss, manifest hit** — fall back to `account_address_pools`
(the in-band snapshot is applied earlier in the same tx). Resolved.
3. **Miss in both** — a genuinely undeclared address (not ours, or an SPV
gap-limit edge). The writer **skips** it (with a `warn`) so one
unresolvable row never aborts a whole flush; its balance re-warms once
the address is later derived.

(The spent-only synthetic-row path is exempt: a spent row uses an inert
`account_index` placeholder and is excluded from reads.)

A live `addresses_derived` entry whose address is absent from the manifest
is a **fatal** `DerivedIndexInvariantViolated` — the emitter must attach
the pool snapshot in-band with every derivation, so this can only fire on
an emitter bug, never on a benign gap.

> The non-ECDSA pool gap (BLS/EdDSA addresses are dropped from the event
> projection, so they never produce an `addresses_derived` entry) cannot
> manifest here: only ECDSA Standard/CoinJoin External/Internal addresses
> are ever classified `Received`/`Change`, so a non-ECDSA address can never
> be a `new_utxos` UTXO address. This is an upstream classifier property
> (`key-wallet` `account_checker`), not enforceable at the storage layer.

- PK: `(wallet_id, account_type, account_index, pool_type, derivation_index)` — the BIP32
leaf identity (one row per derived address).
- `UNIQUE(wallet_id, address)` — the read-index invariant (one
account_index per address); its index also backs the address lookup, so
no separate index is needed. `address` is a derived attribute, never a
key, so every collision surfaces loud.
- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`.
- Index: `idx_core_derived_addresses_addr(wallet_id, address)`.

### `core_sync_state`

Expand Down Expand Up @@ -604,6 +640,7 @@ fifth (`contacts.state`) is a synthetic lifecycle label naming which
| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` |
| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` |
| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` |
| `core_derived_addresses` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` |
| `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` |
| `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` |

Expand Down
14 changes: 10 additions & 4 deletions packages/rs-platform-wallet-storage/migrations/V001__initial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,21 @@ CREATE TABLE core_derived_addresses (
wallet_id BLOB NOT NULL,
account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}),
account_index INTEGER NOT NULL,
pool_type TEXT NOT NULL CHECK (pool_type IN {pool_type_check}),
derivation_index INTEGER NOT NULL,
address TEXT NOT NULL,
derivation_path TEXT NOT NULL,
used INTEGER NOT NULL,
PRIMARY KEY (wallet_id, account_type, address),
-- PK is the BIP32 leaf identity: the full tuple (wallet, account_type,
-- account_index, pool, derivation_index) uniquely identifies one derived
-- leaf. `account_type` uses distinct labels per StandardAccountType
-- variant so BIP32 and BIP44 standard accounts never collapse. `address`
-- is a derived attribute — cross-leaf collisions trip UNIQUE(address).
-- The UNIQUE index also backs ACCOUNT_INDEX_BY_ADDRESS_SQL.
PRIMARY KEY (wallet_id, account_type, account_index, pool_type, derivation_index),
UNIQUE (wallet_id, address),
FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Comment thread
Claudius-Maginificent marked this conversation as resolved.

CREATE INDEX idx_core_derived_addresses_addr ON core_derived_addresses(wallet_id, address);

CREATE TABLE core_sync_state (
wallet_id BLOB NOT NULL PRIMARY KEY,
last_processed_height INTEGER,
Expand Down
23 changes: 20 additions & 3 deletions packages/rs-platform-wallet-storage/src/sqlite/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,27 @@ pub enum WalletStorageError {
},

/// An unspent UTXO named an address absent from
/// `core_derived_addresses`, so its account index can't be resolved;
/// persisting it would mis-file live funds, so the write is refused.
/// Spent-only placeholder rows tolerate a missing mapping.
/// `core_derived_addresses`, so its account index can't be resolved.
/// Retained as a fatal-classified typed marker; the apply path no
/// longer raises it — it skips such a UTXO (logged) so one
/// unresolvable row never aborts a whole flush, and the balance
/// re-warms when the address later derives.
#[error("unspent utxo address {address} is not in core_derived_addresses")]
UtxoAddressNotDerived { address: String },

/// A live `addresses_derived` entry arrived without its address in the
/// wallet's `account_address_pools` manifest. The emitter must attach a
/// full pool snapshot in-band with every derivation, so a derived
/// address absent from the manifest means the emitter contract is
/// broken — a logic regression, not a benign SPV gap. Failing loud at
/// the storage trust boundary surfaces it instead of persisting a row
/// the manifest can't vouch for.
#[error(
"emitter contract violated: derived address {address} is absent from the \
account_address_pools manifest (pool snapshot not emitted in-band)"
)]
DerivedIndexInvariantViolated { address: String },

/// `PRAGMA foreign_keys = ON` was issued on open but the read-back
/// reported the constraint enforcement is still off — the linked
/// SQLite build silently ignores the pragma (no FK support compiled
Expand Down Expand Up @@ -373,6 +388,7 @@ impl WalletStorageError {
| Self::AssetLockEntryMismatch { .. }
| Self::BlobTooLarge { .. }
| Self::UtxoAddressNotDerived { .. }
| Self::DerivedIndexInvariantViolated { .. }
| Self::IntegerOverflow { .. } => false,
}
}
Expand Down Expand Up @@ -450,6 +466,7 @@ impl WalletStorageError {
Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch",
Self::BlobTooLarge { .. } => "blob_too_large",
Self::UtxoAddressNotDerived { .. } => "utxo_address_not_derived",
Self::DerivedIndexInvariantViolated { .. } => "derived_index_invariant_violated",
Self::IntegerOverflow { .. } => "integer_overflow",
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,13 @@ pub fn load_state(
/// `migrations/V001__initial.rs` interpolates it into each table's
/// `CHECK (account_type IN (...))`; `account_type_labels_match_enum` keeps it
/// in sync with [`account_type_db_label`].
///
/// `Standard` maps to two distinct labels by `StandardAccountType` variant
/// (`"standard_bip44"` / `"standard_bip32"`) so BIP44 and BIP32 standard
/// accounts with the same index never collide on their shared PK columns.
pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[
"standard",
"standard_bip44",
"standard_bip32",
"coinjoin",
"identity_registration",
"identity_topup",
Expand All @@ -215,13 +220,22 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[
pub(crate) const POOL_TYPE_LABELS: &[&str] = &["external", "internal", "absent", "absent_hardened"];

/// Stable database label for an `AccountType` variant (the `Debug` impl is not
/// a stable format; this match is the contract). Variants sharing a label are
/// distinguished by the companion `account_index` column. An added upstream
/// variant fails this match's exhaustiveness check at compile time.
/// a stable format; this match is the contract). An added upstream variant
/// fails this match's exhaustiveness check at compile time.
///
/// `Standard` maps to two distinct labels by `StandardAccountType` so BIP44
/// and BIP32 accounts with the same `index` never collapse onto the same PK.
pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str {
use key_wallet::account::AccountType;
use key_wallet::account::{AccountType, StandardAccountType};
match at {
AccountType::Standard { .. } => "standard",
AccountType::Standard {
standard_account_type: StandardAccountType::BIP44Account,
..
} => "standard_bip44",
AccountType::Standard {
standard_account_type: StandardAccountType::BIP32Account,
..
} => "standard_bip32",
AccountType::CoinJoin { .. } => "coinjoin",
AccountType::IdentityRegistration => "identity_registration",
AccountType::IdentityTopUp { .. } => "identity_topup",
Expand Down Expand Up @@ -282,14 +296,20 @@ mod tests {
use std::collections::HashSet;

/// Every [`key_wallet::account::AccountType`] variant; the wildcard-free
/// match below fails to compile if upstream adds one.
/// match below fails to compile if upstream adds one. `Standard` appears
/// twice — once per `StandardAccountType` — because both map to distinct
/// labels.
fn all_account_type_variants() -> Vec<key_wallet::account::AccountType> {
use key_wallet::account::{AccountType, StandardAccountType};
let variants = vec![
AccountType::Standard {
index: 0,
standard_account_type: StandardAccountType::BIP44Account,
},
AccountType::Standard {
index: 0,
standard_account_type: StandardAccountType::BIP32Account,
},
AccountType::CoinJoin { index: 0 },
AccountType::IdentityRegistration,
AccountType::IdentityTopUp {
Expand Down
Loading
Loading